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 +500 -264 2.6.6 → 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);
@@ -124,8 +239,13 @@
124 239 }
125 240
126 241 public function lgSendCustomerParams()
127 242 {
243 + check_ajax_referer('metasync_nonce', 'nonce');
244 + if (!current_user_can('manage_options')) {
245 + wp_send_json_error('Unauthorized');
246 + return;
247 + }
128 248 $sync_request = new Metasync_Sync_Requests();
129 249
130 250 # use the existing apikey for backward compatibility
131 251 $general_options = Metasync::get_option('general') ?? [];
@@ -130,10 +250,14 @@
130 250 # use the existing apikey for backward compatibility
131 251 $general_options = Metasync::get_option('general') ?? [];
132 252 $token = $general_options['apikey'] ?? null;
133 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 +
134 258 # get the response
135 - $response = $sync_request->SyncCustomerParams($token);
259 + $response = $sync_request->SyncCustomerParams($token, $is_heartbeat_tick ? 'heartbeat' : 'manual');
136 260
137 261 // Check if response is a throttling error object
138 262 if (is_object($response) && isset($response->throttled) && $response->throttled === true) {
139 263 wp_send_json($response);
@@ -149,11 +273,14 @@
149 273 $responseBody = wp_remote_retrieve_body($response);
150 274 $responseCode = wp_remote_retrieve_response_code($response);
151 275
152 276 if ($responseCode == 200) {
153 - $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.
154 281 $send_auth_token_timestamp = Metasync::get_option();
155 - $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');
156 283 Metasync::set_option($send_auth_token_timestamp);
157 284
158 285 Metasync_Heartbeat_Manager::instance()->update_heartbeat_cache_after_sync(true, 'Sync Now - successful data sync');
159 286
@@ -202,10 +329,14 @@
202 329 wp_send_json_error(array('message' => 'Insufficient permissions'));
203 330 }
204 331
205 332 $step = isset($_POST['step']) ? intval($_POST['step']) : 0;
206 - $data = isset($_POST['data']) ? $_POST['data'] : array();
333 + $raw_data = isset($_POST['data']) ? $_POST['data'] : array();
207 334
335 + // Allowlist top-level wizard data keys to prevent mass assignment
336 + $allowed_wizard_keys = array('verification', 'seo_settings', 'schema', 'analytics');
337 + $data = is_array($raw_data) ? array_intersect_key($raw_data, array_flip($allowed_wizard_keys)) : array();
338 +
208 339 $options = get_option('metasync_options', array());
209 340
210 341 if (isset($data['verification'])) {
211 342 if (!isset($options['general'])) {
@@ -233,8 +364,13 @@
233 364 $options['general']['enable_schema_markup'] = $data['schema']['enabled'];
234 365 $options['general']['default_schema_type'] = sanitize_text_field($data['schema']['default_type']);
235 366 }
236 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 +
237 373 update_option('metasync_options', $options);
238 374
239 375 wp_send_json_success(array('message' => 'Progress saved'));
240 376 }
@@ -256,13 +392,20 @@
256 392
257 393 $user_id = get_current_user_id();
258 394 delete_transient("metasync_wizard_state_{$user_id}");
259 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 +
260 402 wp_send_json_success(array('message' => 'Wizard completed'));
261 403 }
262 404
263 405 public function ajax_validate_robots()
264 406 {
407 + check_ajax_referer('metasync_nonce', 'nonce');
265 408 if (!Metasync::current_user_has_plugin_access()) {
266 409 wp_send_json_error('Insufficient permissions');
267 410 return;
268 411 }
@@ -277,8 +420,9 @@
277 420 }
278 421
279 422 public function ajax_get_default_robots()
280 423 {
424 + check_ajax_referer('metasync_nonce', 'nonce');
281 425 if (!Metasync::current_user_has_plugin_access()) {
282 426 wp_send_json_error('Insufficient permissions');
283 427 return;
284 428 }
@@ -292,8 +436,9 @@
292 436 }
293 437
294 438 public function ajax_preview_robots_backup()
295 439 {
440 + check_ajax_referer('metasync_nonce', 'nonce');
296 441 if (!Metasync::current_user_has_plugin_access()) {
297 442 wp_send_json_error('Insufficient permissions');
298 443 return;
299 444 }
@@ -316,9 +461,9 @@
316 461 }
317 462
318 463 wp_send_json_success(array(
319 464 'content' => $backup['content'],
320 - 'created_at' => $backup['created_at'],
465 + 'created_at' => get_date_from_gmt($backup['created_at'], get_option('date_format') . ' ' . get_option('time_format')),
321 466 'created_by_name' => isset($backup['created_by_name']) ? $backup['created_by_name'] : ''
322 467 ));
323 468 }
324 469
@@ -392,8 +537,48 @@
392 537 }
393 538 }
394 539 }
395 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 +
396 581 public function ajax_create_redirect_from_404()
397 582 {
398 583 if (empty($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_404_redirect')) {
399 584 wp_die('Security check failed');
@@ -402,10 +587,10 @@
402 587 if (!Metasync::current_user_has_plugin_access()) {
403 588 wp_die('Insufficient permissions');
404 589 }
405 590
406 - $uri = sanitize_text_field($_POST['uri']);
407 - $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']));
408 593
409 594 if (empty($uri) || empty($redirect_url)) {
410 595 wp_send_json_error('Missing required parameters');
411 596 }
@@ -423,178 +608,41 @@
423 608 wp_send_json_error('Failed to create redirect');
424 609 }
425 610 }
426 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 + */
427 618 public function ajax_test_host_blocking_get()
428 619 {
429 - if (!Metasync::current_user_has_plugin_access()) {
430 - wp_send_json_error('Insufficient permissions');
431 - return;
432 - }
620 + $this->send_host_blocking_result('GET');
621 + }
433 622
434 - $endpoint = 'https://wp-check.searchatlas.com/ping';
435 - $start_time = microtime(true);
436 -
437 - $response = wp_remote_get($endpoint, array(
438 - 'timeout' => 30,
439 - 'user-agent' => 'MetaSync Plugin Host Test',
440 - 'headers' => array(
441 - 'Accept' => 'application/json',
442 - 'Content-Type' => 'application/json',
443 - 'Origin' => home_url(),
444 - 'Referer' => admin_url(),
445 - 'X-WordPress-Site' => home_url()
446 - )
447 - ));
448 -
449 - $end_time = microtime(true);
450 - $response_time = round(($end_time - $start_time) * 1000, 2);
451 -
452 - if (is_wp_error($response)) {
453 - wp_send_json_success(array(
454 - 'method' => 'GET',
455 - 'status' => 'error',
456 - 'response_time' => $response_time . 'ms',
457 - 'error' => $response->get_error_message(),
458 - 'blocked' => true,
459 - 'details' => 'Request failed - possible blocking detected'
460 - ));
461 - } else {
462 - $status_code = wp_remote_retrieve_response_code($response);
463 - $body = wp_remote_retrieve_body($response);
464 - $headers = wp_remote_retrieve_headers($response);
465 -
466 - $is_blocked = false;
467 - $status_text = 'success';
468 - $details = 'GET request completed successfully';
469 - $parsed_response = null;
470 -
471 - if ($status_code === 200) {
472 - $parsed_response = json_decode($body, true);
473 -
474 - if ($parsed_response && isset($parsed_response['results']['get'])) {
475 - $get_result = $parsed_response['results']['get'];
476 - $get_status_code = isset($get_result['statusCode']) ? $get_result['statusCode'] : null;
477 -
478 - if ($get_status_code !== 200) {
479 - $is_blocked = true;
480 - $status_text = 'error';
481 - $details = "GET request to target site returned status code {$get_status_code} - host blocking detected";
482 - }
483 - } else {
484 - $is_blocked = true;
485 - $status_text = 'error';
486 - $details = 'Unable to parse response structure - possible blocking or endpoint issue';
487 - }
488 - } else {
489 - $is_blocked = true;
490 - $status_text = 'error';
491 - $details = "External endpoint returned status code {$status_code} - possible blocking detected";
492 - }
493 -
494 - wp_send_json_success(array(
495 - 'method' => 'GET',
496 - 'status' => $status_text,
497 - 'response_time' => $response_time . 'ms',
498 - 'status_code' => $status_code,
499 - 'body' => $body,
500 - 'headers' => $headers->getAll(),
501 - 'blocked' => $is_blocked,
502 - 'details' => $details,
503 - 'parsed_response' => $parsed_response
504 - ));
505 - }
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');
506 629 }
507 630
508 - 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)
509 637 {
638 + check_ajax_referer('metasync_nonce', 'nonce');
510 639 if (!Metasync::current_user_has_plugin_access()) {
511 640 wp_send_json_error('Insufficient permissions');
512 641 return;
513 642 }
514 643
515 - $endpoint = 'https://wp-check.searchatlas.com/ping';
516 - $start_time = microtime(true);
517 -
518 - $test_data = array(
519 - 'test' => 'host_blocking_test',
520 - 'timestamp' => current_time('mysql'),
521 - 'source' => 'metasync_plugin',
522 - 'method' => 'POST'
523 - );
524 -
525 - $response = wp_remote_post($endpoint, array(
526 - 'timeout' => 30,
527 - 'user-agent' => 'MetaSync Plugin Host Test',
528 - 'headers' => array(
529 - 'Accept' => 'application/json',
530 - 'Content-Type' => 'application/json',
531 - 'Origin' => home_url(),
532 - 'Referer' => admin_url(),
533 - 'X-WordPress-Site' => home_url()
534 - ),
535 - 'body' => json_encode($test_data)
536 - ));
537 -
538 - $end_time = microtime(true);
539 - $response_time = round(($end_time - $start_time) * 1000, 2);
540 -
541 - if (is_wp_error($response)) {
542 - wp_send_json_success(array(
543 - 'method' => 'POST',
544 - 'status' => 'error',
545 - 'response_time' => $response_time . 'ms',
546 - 'error' => $response->get_error_message(),
547 - 'blocked' => true,
548 - 'details' => 'Request failed - possible blocking detected',
549 - 'sent_data' => $test_data
550 - ));
551 - } else {
552 - $status_code = wp_remote_retrieve_response_code($response);
553 - $body = wp_remote_retrieve_body($response);
554 - $headers = wp_remote_retrieve_headers($response);
555 -
556 - $is_blocked = false;
557 - $status_text = 'success';
558 - $details = 'POST request completed successfully';
559 - $parsed_response = null;
560 -
561 - if ($status_code === 200) {
562 - $parsed_response = json_decode($body, true);
563 -
564 - if ($parsed_response && isset($parsed_response['results']['post'])) {
565 - $post_result = $parsed_response['results']['post'];
566 - $post_status_code = isset($post_result['statusCode']) ? $post_result['statusCode'] : null;
567 -
568 - if ($post_status_code !== 200) {
569 - $is_blocked = true;
570 - $status_text = 'error';
571 - $details = "POST request to target site returned status code {$post_status_code} - host blocking detected";
572 - }
573 - } else {
574 - $is_blocked = true;
575 - $status_text = 'error';
576 - $details = 'Unable to parse response structure - possible blocking or endpoint issue';
577 - }
578 - } else {
579 - $is_blocked = true;
580 - $status_text = 'error';
581 - $details = "External endpoint returned status code {$status_code} - possible blocking detected";
582 - }
583 -
584 - wp_send_json_success(array(
585 - 'method' => 'POST',
586 - 'status' => $status_text,
587 - 'response_time' => $response_time . 'ms',
588 - 'status_code' => $status_code,
589 - 'body' => $body,
590 - 'headers' => $headers->getAll(),
591 - 'blocked' => $is_blocked,
592 - 'details' => $details,
593 - 'sent_data' => $test_data,
594 - 'parsed_response' => $parsed_response
595 - ));
596 - }
644 + wp_send_json_success(Metasync_Host_Blocking_Check::get_instance()->run_check($method));
597 645 }
598 646
599 647 public function execute_transient_cleanup()
600 648 {
@@ -611,9 +659,9 @@
611 659 try {
612 660 delete_expired_transients(true);
613 661 $cleanup_stats['expired_transients'] = 'cleaned_by_wordpress';
614 662
615 - $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
616 664 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_%'",
617 665 ARRAY_A
618 666 );
619 667
@@ -622,9 +670,9 @@
622 670 delete_transient($transient_name);
623 671 $cleanup_stats['plugin_transients']++;
624 672 }
625 673
626 - $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
627 675 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
628 676 ARRAY_A
629 677 );
630 678
@@ -633,9 +681,9 @@
633 681 delete_transient($transient_name);
634 682 $cleanup_stats['rate_limit_transients']++;
635 683 }
636 684
637 - $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
638 686 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_telemetry_%'",
639 687 ARRAY_A
640 688 );
641 689
@@ -644,9 +692,9 @@
644 692 delete_transient($transient_name);
645 693 $cleanup_stats['telemetry_transients']++;
646 694 }
647 695
648 - $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
649 697 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_sa_connect_success_%'",
650 698 ARRAY_A
651 699 );
652 700
@@ -657,17 +705,50 @@
657 705 }
658 706
659 707 $cleanup_stats['execution_time'] = round((microtime(true) - $cleanup_stats['start_time']) * 1000, 2);
660 708 $cleanup_stats['next_run'] = wp_next_scheduled('metasync_cleanup_transients') ?
661 - 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';
662 710
663 - error_log('MetaSync: Transient cleanup completed - ' . json_encode($cleanup_stats));
664 -
665 711 } catch (Exception $e) {
666 - 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
667 713 }
668 714 }
669 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 +
670 751 public function ajax_submit_issue_report()
671 752 {
672 753 try {
673 754 # Verify nonce
@@ -705,16 +786,22 @@
705 786
706 787 # Handle file upload if present
707 788 $attachment = null;
708 789 if (!empty($_FILES['issue_attachment']['tmp_name'])) {
709 - # Validate file type
710 - $allowed_types = array('image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp');
711 - $file_type = $_FILES['issue_attachment']['type'];
712 -
713 - if (!in_array($file_type, $allowed_types, true)) {
790 + # Validate file type using server-side MIME detection (not client-supplied type)
791 + $tmp_name = $_FILES['issue_attachment']['tmp_name'];
792 + $filename = sanitize_file_name($_FILES['issue_attachment']['name']);
793 + $file_info = wp_check_filetype_and_ext($tmp_name, $filename);
794 + if (!$file_info['ext']) {
714 795 wp_send_json_error(array('message' => 'Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.'));
715 796 return;
716 797 }
798 + $allowed_extensions = array('jpg', 'jpeg', 'png', 'gif', 'webp');
799 + if (!in_array($file_info['ext'], $allowed_extensions, true)) {
800 + wp_send_json_error(array('message' => 'Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.'));
801 + return;
802 + }
803 + $file_type = $file_info['type'];
717 804
718 805 # Validate file size (5MB max)
719 806 $max_size = 5 * 1024 * 1024; // 5MB
720 807 if ($_FILES['issue_attachment']['size'] > $max_size) {
@@ -722,12 +809,12 @@
722 809 return;
723 810 }
724 811
725 812 # Read file contents
726 - $file_contents = file_get_contents($_FILES['issue_attachment']['tmp_name']);
813 + $file_contents = file_get_contents($tmp_name);
727 814 if ($file_contents !== false) {
728 815 $attachment = array(
729 - 'filename' => sanitize_file_name($_FILES['issue_attachment']['name']),
816 + 'filename' => $filename,
730 817 'data' => $file_contents,
731 818 'content_type' => $file_type
732 819 );
733 820 }
@@ -732,8 +819,11 @@
732 819 );
733 820 }
734 821 }
735 822
823 + # Read the locally stored white-label settings for the report payload.
824 + $whitelabel_context = self::build_issue_report_whitelabel_context();
825 +
736 826 # Get general options (same way as used throughout the plugin)
737 827 $general_options = Metasync::get_option('general');
738 828 if (!is_array($general_options)) {
739 829 $general_options = array();
@@ -788,14 +878,18 @@
788 878 # Check if Sentry feedback function exists
789 879 if (!function_exists('metasync_sentry_capture_feedback')) {
790 880 # Log warning if function doesn't exist
791 881 if (defined('WP_DEBUG') && WP_DEBUG) {
792 - 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
793 883 }
794 884 } else {
795 885 $feedback_data = array(
796 886 'message' => $issue_message,
797 - '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']
798 892 );
799 893
800 894 # Add user information if requested
801 895 if ($include_user_info) {
@@ -817,13 +911,11 @@
817 911 ));
818 912 } else {
819 913 # Fallback: Log locally if Sentry fails or is unavailable
820 914 if (defined('WP_DEBUG') && WP_DEBUG) {
821 - error_log(sprintf(
822 - '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',
823 917 $project_uuid,
824 - $issue_title,
825 - $issue_message,
826 918 $issue_severity
827 919 ));
828 920 }
829 921
@@ -837,9 +929,9 @@
837 929
838 930 } catch (Exception $e) {
839 931 # Log the error securely (only in debug mode)
840 932 if (defined('WP_DEBUG') && WP_DEBUG) {
841 - error_log(sprintf(
933 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
842 934 'MetaSync Report Submission Error: %s in %s on line %d',
843 935 $e->getMessage(),
844 936 $e->getFile(),
845 937 $e->getLine()
@@ -852,8 +944,9 @@
852 944 }
853 945
854 946 public function ajax_recover_password()
855 947 {
948 + $rate_owner = '';
856 949 try {
857 950 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_recover_password_nonce')) {
858 951 wp_send_json_error(array('message' => 'Security verification failed.'));
859 952 return;
@@ -858,26 +951,87 @@
858 951 wp_send_json_error(array('message' => 'Security verification failed.'));
859 952 return;
860 953 }
861 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 +
862 971 $whitelabel_settings = Metasync::get_whitelabel_settings();
863 - $password = $whitelabel_settings['settings_password'] ?? '';
864 972 $recovery_email = $whitelabel_settings['recovery_email'] ?? '';
865 973
866 - 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 = '';
867 978 wp_send_json_error(array('message' => 'No password is configured for recovery.'));
868 979 return;
869 980 }
870 981
871 982 if (empty($recovery_email) || !is_email($recovery_email)) {
983 + $this->release_recovery_lock($rate_owner);
984 + $rate_owner = '';
872 985 wp_send_json_error(array('message' => 'No valid recovery email is configured. Please contact your administrator.'));
873 986 return;
874 987 }
875 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 +
876 1030 $site_name = get_bloginfo('name');
877 1031 $site_url = home_url();
878 1032 $plugin_name = Metasync::get_effective_plugin_name('');
879 - $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));
880 1034 $to = $recovery_email;
881 1035
882 1036 $subject = sprintf('[%s] Settings Password Recovery', $site_name);
883 1037
@@ -883,16 +1037,14 @@
883 1037
884 1038 $message = sprintf(
885 1039 "Hello,\n\n" .
886 1040 "A password recovery request was made for the %s settings on %s.\n\n" .
887 - "Your Settings Password is:\n%s\n\n" .
888 - "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" .
889 1042 "If you did not request this password recovery, please secure your WordPress admin account immediately.\n\n" .
890 1043 "---\n" .
891 1044 "This is an automated message from %s\n%s",
892 1045 $plugin_name,
893 1046 $site_name,
894 - $password,
895 1047 $settings_url,
896 1048 $site_name,
897 1049 $site_url
898 1050 );
@@ -905,13 +1057,8 @@
905 1057 'Content-Type: text/plain; charset=UTF-8',
906 1058 sprintf('From: %s <%s>', $from_name, get_option('admin_email'))
907 1059 );
908 1060
909 - $mail_error = '';
910 - add_action('wp_mail_failed', function($error) use (&$mail_error) {
911 - $mail_error = $error->get_error_message();
912 - });
913 -
914 1061 $sent = wp_mail($to, $subject, $message, $headers);
915 1062
916 1063 if ($sent) {
917 1064 wp_send_json_success(array(
@@ -917,21 +1064,19 @@
917 1064 wp_send_json_success(array(
918 1065 'message' => sprintf('Password recovery email sent to %s', esc_html($recovery_email))
919 1066 ));
920 1067 } else {
921 - $error_message = 'Failed to send recovery email. ';
922 - if (!empty($mail_error)) {
923 - $error_message .= 'Error: ' . $mail_error;
924 - } else {
925 - $error_message .= 'Your server may not be configured to send emails. Please check your email configuration or contact your administrator.';
926 - }
927 -
928 - 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.'));
929 1069 }
930 1070
931 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 + }
932 1077 if (defined('WP_DEBUG') && WP_DEBUG) {
933 - error_log(sprintf(
1078 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
934 1079 'MetaSync Password Recovery Error: %s in %s on line %d',
935 1080 $e->getMessage(),
936 1081 $e->getFile(),
937 1082 $e->getLine()
@@ -941,8 +1086,113 @@
941 1086 wp_send_json_error(array('message' => 'An error occurred while processing your request. Please try again later.'));
942 1087 }
943 1088 }
944 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 +
945 1195 public function ajax_save_theme()
946 1196 {
947 1197 try {
948 1198 # Verify nonce for security
@@ -947,15 +1197,13 @@
947 1197 try {
948 1198 # Verify nonce for security
949 1199 if (!isset($_POST['_ajax_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_ajax_nonce'])), 'metasync_theme_nonce')) {
950 1200 wp_send_json_error(array('message' => 'Security verification failed.'));
951 - return;
952 1201 }
953 1202
954 1203 # Check user capabilities
955 1204 if (!Metasync::current_user_has_plugin_access()) {
956 1205 wp_send_json_error(array('message' => 'Insufficient permissions.'));
957 - return;
958 1206 }
959 1207
960 1208 # Get and validate theme value
961 1209 $theme = isset($_POST['theme']) ? sanitize_text_field(wp_unslash($_POST['theme'])) : '';
@@ -962,9 +1210,8 @@
962 1210
963 1211 # Validate theme is either 'light' or 'dark'
964 1212 if (!in_array($theme, array('light', 'dark'), true)) {
965 1213 wp_send_json_error(array('message' => 'Invalid theme value.'));
966 - return;
967 1214 }
968 1215
969 1216 # Save theme preference to WordPress options
970 1217 update_option('metasync_theme', $theme, true);
@@ -977,9 +1224,9 @@
977 1224
978 1225 } catch (Exception $e) {
979 1226 # Log error if debug is enabled
980 1227 if (defined('WP_DEBUG') && WP_DEBUG) {
981 - 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
982 1229 }
983 1230
984 1231 wp_send_json_error(array('message' => 'Failed to save theme preference.'));
985 1232 }
@@ -984,14 +1231,14 @@
984 1231 wp_send_json_error(array('message' => 'Failed to save theme preference.'));
985 1232 }
986 1233 }
987 1234
988 - public function ajax_track_one_click_activation()
1235 + public function ajax_track_one_click_activation()
989 1236 {
1237 + check_ajax_referer('metasync_nonce', 'nonce');
990 1238 # Check user capabilities
991 1239 if (!Metasync::current_user_has_plugin_access()) {
992 1240 wp_send_json_error(['message' => 'Insufficient permissions']);
993 - return;
994 1241 }
995 1242
996 1243 # Get parameters
997 1244 $auth_method = isset($_POST['auth_method']) ? sanitize_text_field(wp_unslash($_POST['auth_method'])) : 'searchatlas_connect';
@@ -1033,32 +1280,20 @@
1033 1280 wp_die('Security verification failed.');
1034 1281 }
1035 1282
1036 1283 try {
1037 - # Get all whitelabel settings
1038 - $whitelabel_settings = Metasync::get_whitelabel_settings();
1039 -
1040 - # Get general settings that relate to whitelabel
1041 - $general_settings = Metasync::get_option('general');
1042 - $whitelabel_related_general = array();
1043 -
1044 - # Include ALL whitelabel-related general settings
1045 - $whitelabel_keys = array(
1046 - 'white_label_plugin_name',
1047 - 'white_label_plugin_description',
1048 - 'white_label_plugin_author',
1049 - 'white_label_plugin_author_uri',
1050 - 'white_label_plugin_uri',
1051 - 'white_label_plugin_menu_slug',
1052 - 'white_label_plugin_menu_icon',
1053 - 'whitelabel_otto_name'
1054 - );
1055 -
1056 - foreach ($whitelabel_keys as $key) {
1057 - if (isset($general_settings[$key])) {
1058 - $whitelabel_related_general[$key] = $general_settings[$key];
1059 - }
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';
1060 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'];
1061 1296
1062 1297 # Bundle the menu icon file so it survives import on a different site.
1063 1298 # If the icon is a local URL (media library), replace it with a special
1064 1299 # marker and include the actual file in the ZIP under the plugin folder.
@@ -1112,9 +1347,9 @@
1112 1347 wp_mkdir_p($temp_dir);
1113 1348 }
1114 1349
1115 1350 # Generate unique filename
1116 - $timestamp = date('Y-m-d_H-i-s');
1351 + $timestamp = gmdate('Y-m-d_H-i-s');
1117 1352 $zip_filename = 'metasync-whitelabel-plugin-' . $timestamp . '.zip';
1118 1353 $json_filename = 'whitelabel-settings.json';
1119 1354 $zip_path = $temp_dir . '/' . $zip_filename;
1120 1355
@@ -1175,14 +1410,14 @@
1175 1410 header('Pragma: no-cache');
1176 1411 header('Expires: 0');
1177 1412
1178 1413 # Output file and clean up
1179 - readfile($zip_path);
1180 - 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);
1181 1416
1182 1417 # Clean up temp directory if empty
1183 1418 if (is_dir($temp_dir) && count(scandir($temp_dir)) == 2) {
1184 - 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
1185 1420 }
1186 1421
1187 1422 # Exit to prevent WordPress from adding anything to the response
1188 1423 exit;
@@ -1189,12 +1424,12 @@
1189 1424
1190 1425 } catch (Exception $e) {
1191 1426 # Log error if debug is enabled
1192 1427 if (defined('WP_DEBUG') && WP_DEBUG) {
1193 - 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
1194 1429 }
1195 1430
1196 - wp_die('Failed to export whitelabel settings: ' . $e->getMessage());
1431 + wp_die(esc_html__('Failed to export whitelabel settings.', 'metasync'));
1197 1432 }
1198 1433 }
1199 1434
1200 1435 private static function add_directory_to_zip($zip, $dir, $zip_path = '', $exclude_patterns = array())
@@ -1239,10 +1474,10 @@
1239 1474 public function render_html_pages_dashboard_widget()
1240 1475 {
1241 1476 global $wpdb;
1242 1477
1243 - $query = "
1244 - 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
1245 1480 FROM {$wpdb->posts} p
1246 1481 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
1247 1482 WHERE p.post_status = 'publish'
1248 1483 AND p.post_type IN ('post', 'page')
@@ -1249,11 +1484,10 @@
1249 1484 AND (pm.meta_key = '_metasync_raw_html_enabled' OR pm.meta_key = '_metasync_custom_css')
1250 1485 GROUP BY p.ID
1251 1486 ORDER BY p.post_modified DESC
1252 1487 LIMIT 10
1253 - ";
1254 -
1255 - $html_pages = $wpdb->get_results($query);
1488 + "
1489 + );
1256 1490 $total_count = count($html_pages);
1257 1491
1258 1492 $label = $this->get_html_source_label();
1259 1493
@@ -1260,13 +1494,14 @@
1260 1494 if (empty($html_pages)) {
1261 1495 echo '<div class="metasync-dashboard-widget-empty">';
1262 1496 echo '<span class="dashicons dashicons-admin-page" style="font-size: 48px; opacity: 0.3; display: block; margin: 20px auto;"></span>';
1263 1497 echo '<p style="text-align: center; color: #666;">';
1264 - 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>');
1265 1500 echo '</p>';
1266 1501 echo '<p style="text-align: center;">';
1267 - echo '<a href="' . admin_url('admin.php?page=' . Metasync_Admin::$page_slug) . '" class="button button-primary">';
1268 - 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');
1269 1504 echo '</a>';
1270 1505 echo '</p>';
1271 1506 echo '</div>';
1272 1507 return;
@@ -1275,15 +1510,15 @@
1275 1510 echo '<div class="metasync-dashboard-widget">';
1276 1511
1277 1512 echo '<div class="metasync-widget-stats">';
1278 1513 echo '<div class="metasync-stat-box">';
1279 - echo '<span class="metasync-stat-number">' . $total_count . '</span>';
1280 - 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>';
1281 1516 echo '</div>';
1282 1517 echo '</div>';
1283 1518
1284 1519 echo '<div class="metasync-widget-list">';
1285 - echo '<h4>' . __('Recent Pages', 'metasync') . '</h4>';
1520 + echo '<h4>' . esc_html__('Recent Pages', 'metasync') . '</h4>';
1286 1521 echo '<ul>';
1287 1522
1288 1523 foreach ($html_pages as $page) {
1289 1524 $edit_link = get_edit_post_link($page->ID);
@@ -1296,11 +1531,12 @@
1296 1531 echo '<a href="' . esc_url($edit_link) . '" class="metasync-page-title">';
1297 1532 echo esc_html($page->post_title ?: __('(no title)', 'metasync'));
1298 1533 echo '</a>';
1299 1534 echo '<span class="metasync-page-meta">';
1300 - 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));
1301 1537 echo ' • ';
1302 - 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>';
1303 1539 echo '</span>';
1304 1540 echo '</div>';
1305 1541 echo '</li>';
1306 1542 }
@@ -1308,10 +1544,10 @@
1308 1544 echo '</ul>';
1309 1545 echo '</div>';
1310 1546
1311 1547 echo '<div class="metasync-widget-footer">';
1312 - echo '<a href="' . admin_url('edit.php?post_type=page') . '">';
1313 - 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') . ' →';
1314 1550 echo '</a>';
1315 1551 echo '</div>';
1316 1552
1317 1553 echo '</div>';