PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
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 +430 -240 2.6.6 → 2.7.1 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;
@@ -81,9 +179,9 @@
81 179 public function ajax_import_seo_metadata()
82 180 {
83 181 $execution_time = Metasync_Settings_Fields::instance()->get_execution_setting('max_execution_time');
84 182 if (function_exists('set_time_limit')) {
85 - @set_time_limit($execution_time);
183 + @set_time_limit($execution_time); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- runtime PHP setting with no WordPress API
86 184 }
87 185
88 186 Metasync_Settings_Fields::instance()->apply_memory_limit();
89 187
@@ -92,11 +190,13 @@
92 190 if (!Metasync::current_user_has_plugin_access()) {
93 191 wp_send_json_error(['message' => 'Insufficient permissions.']);
94 192 }
95 193
96 - $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : '';
194 + $plugin = isset($_POST['plugin']) ? sanitize_text_field(wp_unslash($_POST['plugin'])) : '';
97 195 $import_titles = isset($_POST['import_titles']) ? (bool) intval($_POST['import_titles']) : true;
98 196 $import_descriptions = isset($_POST['import_descriptions']) ? (bool) intval($_POST['import_descriptions']) : true;
197 + $import_social_text = isset($_POST['import_social_text']) ? (bool) intval($_POST['import_social_text']) : true;
198 + $import_social_images = isset($_POST['import_social_images']) ? (bool) intval($_POST['import_social_images']) : true;
99 199 $overwrite_existing = isset($_POST['overwrite_existing']) ? (bool) intval($_POST['overwrite_existing']) : false;
100 200 $offset = isset($_POST['offset']) ? intval($_POST['offset']) : 0;
101 201
102 202 if (empty($plugin)) {
@@ -108,8 +208,10 @@
108 208
109 209 $options = [
110 210 'import_titles' => $import_titles,
111 211 'import_descriptions' => $import_descriptions,
212 + 'import_social_text' => $import_social_text,
213 + 'import_social_images' => $import_social_images,
112 214 'overwrite_existing' => $overwrite_existing,
113 215 'batch_size' => 50,
114 216 'offset' => $offset
115 217 ];
@@ -124,8 +226,13 @@
124 226 }
125 227
126 228 public function lgSendCustomerParams()
127 229 {
230 + check_ajax_referer('metasync_nonce', 'nonce');
231 + if (!current_user_can('manage_options')) {
232 + wp_send_json_error('Unauthorized');
233 + return;
234 + }
128 235 $sync_request = new Metasync_Sync_Requests();
129 236
130 237 # use the existing apikey for backward compatibility
131 238 $general_options = Metasync::get_option('general') ?? [];
@@ -130,10 +237,14 @@
130 237 # use the existing apikey for backward compatibility
131 238 $general_options = Metasync::get_option('general') ?? [];
132 239 $token = $general_options['apikey'] ?? null;
133 240
241 + # declare the call context explicitly so only the Settings
242 + # "Sync Now" button consumes/stamps the 5-minute manual cooldown.
243 + $is_heartbeat_tick = filter_var($_POST['is_heart_beat'] ?? false, FILTER_VALIDATE_BOOLEAN);
244 +
134 245 # get the response
135 - $response = $sync_request->SyncCustomerParams($token);
246 + $response = $sync_request->SyncCustomerParams($token, $is_heartbeat_tick ? 'heartbeat' : 'manual');
136 247
137 248 // Check if response is a throttling error object
138 249 if (is_object($response) && isset($response->throttled) && $response->throttled === true) {
139 250 wp_send_json($response);
@@ -149,11 +260,14 @@
149 260 $responseBody = wp_remote_retrieve_body($response);
150 261 $responseCode = wp_remote_retrieve_response_code($response);
151 262
152 263 if ($responseCode == 200) {
153 - $dt = new DateTime();
264 + # Standardize on current_time('mysql') (WP local timezone) so the
265 + # value matches what the cron heartbeat writes — otherwise the
266 + # displayed timestamp flickers between two formats depending on
267 + # which path wrote last.
154 268 $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');;
269 + $send_auth_token_timestamp['general']['send_auth_token_timestamp'] = current_time('mysql');
156 270 Metasync::set_option($send_auth_token_timestamp);
157 271
158 272 Metasync_Heartbeat_Manager::instance()->update_heartbeat_cache_after_sync(true, 'Sync Now - successful data sync');
159 273
@@ -202,10 +316,14 @@
202 316 wp_send_json_error(array('message' => 'Insufficient permissions'));
203 317 }
204 318
205 319 $step = isset($_POST['step']) ? intval($_POST['step']) : 0;
206 - $data = isset($_POST['data']) ? $_POST['data'] : array();
320 + $raw_data = isset($_POST['data']) ? $_POST['data'] : array();
207 321
322 + // Allowlist top-level wizard data keys to prevent mass assignment
323 + $allowed_wizard_keys = array('verification', 'seo_settings', 'schema');
324 + $data = is_array($raw_data) ? array_intersect_key($raw_data, array_flip($allowed_wizard_keys)) : array();
325 +
208 326 $options = get_option('metasync_options', array());
209 327
210 328 if (isset($data['verification'])) {
211 329 if (!isset($options['general'])) {
@@ -261,8 +379,9 @@
261 379 }
262 380
263 381 public function ajax_validate_robots()
264 382 {
383 + check_ajax_referer('metasync_nonce', 'nonce');
265 384 if (!Metasync::current_user_has_plugin_access()) {
266 385 wp_send_json_error('Insufficient permissions');
267 386 return;
268 387 }
@@ -277,8 +396,9 @@
277 396 }
278 397
279 398 public function ajax_get_default_robots()
280 399 {
400 + check_ajax_referer('metasync_nonce', 'nonce');
281 401 if (!Metasync::current_user_has_plugin_access()) {
282 402 wp_send_json_error('Insufficient permissions');
283 403 return;
284 404 }
@@ -292,8 +412,9 @@
292 412 }
293 413
294 414 public function ajax_preview_robots_backup()
295 415 {
416 + check_ajax_referer('metasync_nonce', 'nonce');
296 417 if (!Metasync::current_user_has_plugin_access()) {
297 418 wp_send_json_error('Insufficient permissions');
298 419 return;
299 420 }
@@ -316,9 +437,9 @@
316 437 }
317 438
318 439 wp_send_json_success(array(
319 440 'content' => $backup['content'],
320 - 'created_at' => $backup['created_at'],
441 + 'created_at' => get_date_from_gmt($backup['created_at'], get_option('date_format') . ' ' . get_option('time_format')),
321 442 'created_by_name' => isset($backup['created_by_name']) ? $backup['created_by_name'] : ''
322 443 ));
323 444 }
324 445
@@ -392,8 +513,48 @@
392 513 }
393 514 }
394 515 }
395 516
517 + public function ajax_get_robots_backups()
518 + {
519 + if (!Metasync::current_user_has_plugin_access()) {
520 + wp_send_json_error(array('message' => esc_html__('Insufficient permissions', 'metasync')));
521 + return;
522 + }
523 +
524 + if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'metasync_get_robots_backups')) {
525 + wp_send_json_error(array('message' => esc_html__('Security check failed', 'metasync')));
526 + return;
527 + }
528 +
529 + require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
530 + $robots_txt = Metasync_Robots_Txt::get_instance();
531 +
532 + $backup_per_page = Metasync_Robots_Txt_Database::BACKUPS_PER_PAGE;
533 + $backup_total = $robots_txt->get_backup_count();
534 + $backup_total_pages = max(1, (int) ceil($backup_total / $backup_per_page));
535 +
536 + $backup_page = isset($_POST['page']) ? max(1, intval($_POST['page'])) : 1;
537 + if ($backup_page > $backup_total_pages) {
538 + $backup_page = $backup_total_pages;
539 + }
540 +
541 + $offset = ($backup_page - 1) * $backup_per_page;
542 + $backups = $robots_txt->get_backup_history($backup_per_page, $offset);
543 +
544 + // Render the shared list + pagination partial to a string.
545 + ob_start();
546 + require plugin_dir_path(dirname(__FILE__)) . 'robots-txt/views/backup-history.php';
547 + $html = ob_get_clean();
548 +
549 + wp_send_json_success(array(
550 + 'html' => $html,
551 + 'total' => $backup_total,
552 + 'page' => $backup_page,
553 + 'total_pages' => $backup_total_pages,
554 + ));
555 + }
556 +
396 557 public function ajax_create_redirect_from_404()
397 558 {
398 559 if (empty($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_404_redirect')) {
399 560 wp_die('Security check failed');
@@ -402,10 +563,10 @@
402 563 if (!Metasync::current_user_has_plugin_access()) {
403 564 wp_die('Insufficient permissions');
404 565 }
405 566
406 - $uri = sanitize_text_field($_POST['uri']);
407 - $redirect_url = sanitize_url($_POST['redirect_url']);
567 + $uri = sanitize_text_field(wp_unslash($_POST['uri']));
568 + $redirect_url = sanitize_url(wp_unslash($_POST['redirect_url']));
408 569
409 570 if (empty($uri) || empty($redirect_url)) {
410 571 wp_send_json_error('Missing required parameters');
411 572 }
@@ -423,178 +584,41 @@
423 584 wp_send_json_error('Failed to create redirect');
424 585 }
425 586 }
426 587
588 + /**
589 + * Manual "Host Blocking Test" — GET leg.
590 + *
591 + * The probe itself lives in Metasync_Host_Blocking_Check so the manual button and the
592 + * automatic activation/weekly check share one implementation.
593 + */
427 594 public function ajax_test_host_blocking_get()
428 595 {
429 - if (!Metasync::current_user_has_plugin_access()) {
430 - wp_send_json_error('Insufficient permissions');
431 - return;
432 - }
596 + $this->send_host_blocking_result('GET');
597 + }
433 598
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 - }
599 + /**
600 + * Manual "Host Blocking Test" — POST leg.
601 + */
602 + public function ajax_test_host_blocking_post()
603 + {
604 + $this->send_host_blocking_result('POST');
506 605 }
507 606
508 - public function ajax_test_host_blocking_post()
607 + /**
608 + * Shared auth + response wrapper for both manual host blocking legs.
609 + *
610 + * @param string $method 'GET' or 'POST'.
611 + */
612 + private function send_host_blocking_result($method)
509 613 {
614 + check_ajax_referer('metasync_nonce', 'nonce');
510 615 if (!Metasync::current_user_has_plugin_access()) {
511 616 wp_send_json_error('Insufficient permissions');
512 617 return;
513 618 }
514 619
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 - }
620 + wp_send_json_success(Metasync_Host_Blocking_Check::get_instance()->run_check($method));
597 621 }
598 622
599 623 public function execute_transient_cleanup()
600 624 {
@@ -611,9 +635,9 @@
611 635 try {
612 636 delete_expired_transients(true);
613 637 $cleanup_stats['expired_transients'] = 'cleaned_by_wordpress';
614 638
615 - $plugin_transients = $wpdb->get_results(
639 + $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 640 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_%'",
617 641 ARRAY_A
618 642 );
619 643
@@ -622,9 +646,9 @@
622 646 delete_transient($transient_name);
623 647 $cleanup_stats['plugin_transients']++;
624 648 }
625 649
626 - $rate_limit_transients = $wpdb->get_results(
650 + $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 651 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
628 652 ARRAY_A
629 653 );
630 654
@@ -633,9 +657,9 @@
633 657 delete_transient($transient_name);
634 658 $cleanup_stats['rate_limit_transients']++;
635 659 }
636 660
637 - $telemetry_transients = $wpdb->get_results(
661 + $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 662 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_telemetry_%'",
639 663 ARRAY_A
640 664 );
641 665
@@ -644,9 +668,9 @@
644 668 delete_transient($transient_name);
645 669 $cleanup_stats['telemetry_transients']++;
646 670 }
647 671
648 - $sa_connect_success_transients = $wpdb->get_results(
672 + $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 673 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_sa_connect_success_%'",
650 674 ARRAY_A
651 675 );
652 676
@@ -657,14 +681,12 @@
657 681 }
658 682
659 683 $cleanup_stats['execution_time'] = round((microtime(true) - $cleanup_stats['start_time']) * 1000, 2);
660 684 $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';
685 + gmdate('Y-m-d H:i:s T', wp_next_scheduled('metasync_cleanup_transients')) : 'N/A';
662 686
663 - error_log('MetaSync: Transient cleanup completed - ' . json_encode($cleanup_stats));
664 -
665 687 } catch (Exception $e) {
666 - error_log('MetaSync: Transient cleanup failed - ' . $e->getMessage());
688 + error_log('MetaSync: Transient cleanup failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
667 689 }
668 690 }
669 691
670 692 public function ajax_submit_issue_report()
@@ -705,16 +727,22 @@
705 727
706 728 # Handle file upload if present
707 729 $attachment = null;
708 730 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)) {
731 + # Validate file type using server-side MIME detection (not client-supplied type)
732 + $tmp_name = $_FILES['issue_attachment']['tmp_name'];
733 + $filename = sanitize_file_name($_FILES['issue_attachment']['name']);
734 + $file_info = wp_check_filetype_and_ext($tmp_name, $filename);
735 + if (!$file_info['ext']) {
714 736 wp_send_json_error(array('message' => 'Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.'));
715 737 return;
716 738 }
739 + $allowed_extensions = array('jpg', 'jpeg', 'png', 'gif', 'webp');
740 + if (!in_array($file_info['ext'], $allowed_extensions, true)) {
741 + wp_send_json_error(array('message' => 'Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.'));
742 + return;
743 + }
744 + $file_type = $file_info['type'];
717 745
718 746 # Validate file size (5MB max)
719 747 $max_size = 5 * 1024 * 1024; // 5MB
720 748 if ($_FILES['issue_attachment']['size'] > $max_size) {
@@ -722,12 +750,12 @@
722 750 return;
723 751 }
724 752
725 753 # Read file contents
726 - $file_contents = file_get_contents($_FILES['issue_attachment']['tmp_name']);
754 + $file_contents = file_get_contents($tmp_name);
727 755 if ($file_contents !== false) {
728 756 $attachment = array(
729 - 'filename' => sanitize_file_name($_FILES['issue_attachment']['name']),
757 + 'filename' => $filename,
730 758 'data' => $file_contents,
731 759 'content_type' => $file_type
732 760 );
733 761 }
@@ -788,9 +816,9 @@
788 816 # Check if Sentry feedback function exists
789 817 if (!function_exists('metasync_sentry_capture_feedback')) {
790 818 # Log warning if function doesn't exist
791 819 if (defined('WP_DEBUG') && WP_DEBUG) {
792 - error_log('MetaSync: Sentry feedback function not available for report submission.');
820 + 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 821 }
794 822 } else {
795 823 $feedback_data = array(
796 824 'message' => $issue_message,
@@ -817,13 +845,11 @@
817 845 ));
818 846 } else {
819 847 # Fallback: Log locally if Sentry fails or is unavailable
820 848 if (defined('WP_DEBUG') && WP_DEBUG) {
821 - error_log(sprintf(
822 - 'MetaSync Client Report (Fallback): UUID: %s | Title: %s | Message: %s | Severity: %s',
849 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
850 + 'MetaSync Client Report (Fallback): UUID: %s | Severity: %s',
823 851 $project_uuid,
824 - $issue_title,
825 - $issue_message,
826 852 $issue_severity
827 853 ));
828 854 }
829 855
@@ -837,9 +863,9 @@
837 863
838 864 } catch (Exception $e) {
839 865 # Log the error securely (only in debug mode)
840 866 if (defined('WP_DEBUG') && WP_DEBUG) {
841 - error_log(sprintf(
867 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
842 868 'MetaSync Report Submission Error: %s in %s on line %d',
843 869 $e->getMessage(),
844 870 $e->getFile(),
845 871 $e->getLine()
@@ -852,8 +878,9 @@
852 878 }
853 879
854 880 public function ajax_recover_password()
855 881 {
882 + $rate_owner = '';
856 883 try {
857 884 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_recover_password_nonce')) {
858 885 wp_send_json_error(array('message' => 'Security verification failed.'));
859 886 return;
@@ -858,26 +885,87 @@
858 885 wp_send_json_error(array('message' => 'Security verification failed.'));
859 886 return;
860 887 }
861 888
889 + if (!current_user_can('manage_options')) {
890 + wp_send_json_error(array('message' => 'You do not have permission to request a password recovery.'), 403);
891 + return;
892 + }
893 +
894 + if (!$this->acquire_recovery_lock($rate_owner)) {
895 + wp_send_json_error(array('message' => 'Too many recovery requests. Please try again later.'), 429);
896 + return;
897 + }
898 + register_shutdown_function(function () use (&$rate_owner) {
899 + if ($rate_owner !== '') {
900 + Metasync_Admin_Ajax::instance()->release_recovery_lock($rate_owner);
901 + }
902 + });
903 + $this->refresh_recovery_state_cache();
904 +
862 905 $whitelabel_settings = Metasync::get_whitelabel_settings();
863 - $password = $whitelabel_settings['settings_password'] ?? '';
864 906 $recovery_email = $whitelabel_settings['recovery_email'] ?? '';
865 907
866 - if (empty($password)) {
908 + $stored_password = $whitelabel_settings['settings_password'] ?? '';
909 + if (!is_string($stored_password) || $stored_password === '') {
910 + $this->release_recovery_lock($rate_owner);
911 + $rate_owner = '';
867 912 wp_send_json_error(array('message' => 'No password is configured for recovery.'));
868 913 return;
869 914 }
870 915
871 916 if (empty($recovery_email) || !is_email($recovery_email)) {
917 + $this->release_recovery_lock($rate_owner);
918 + $rate_owner = '';
872 919 wp_send_json_error(array('message' => 'No valid recovery email is configured. Please contact your administrator.'));
873 920 return;
874 921 }
875 922
923 + // Bound recovery requests per user and per site to limit abuse.
924 + $user_id = get_current_user_id();
925 + $user_rate_key = 'metasync_pw_recovery_user_' . $user_id;
926 + $site_rate_key = 'metasync_pw_recovery_site';
927 + $this->refresh_recovery_state_cache($user_rate_key);
928 + $this->refresh_recovery_state_cache($site_rate_key);
929 + $user_attempts = (int) get_transient($user_rate_key);
930 + $site_attempts = (int) get_transient($site_rate_key);
931 + if ($user_attempts >= 3 || $site_attempts >= 10) {
932 + $this->release_recovery_lock($rate_owner);
933 + wp_send_json_error(array('message' => 'Too many recovery requests. Please try again later.'), 429);
934 + return;
935 + }
936 +
937 + if (!set_transient($user_rate_key, $user_attempts + 1, HOUR_IN_SECONDS) || !set_transient($site_rate_key, $site_attempts + 1, HOUR_IN_SECONDS)) {
938 + if ($user_attempts > 0) set_transient($user_rate_key, $user_attempts, HOUR_IN_SECONDS);
939 + else delete_transient($user_rate_key);
940 + if ($site_attempts > 0) set_transient($site_rate_key, $site_attempts, HOUR_IN_SECONDS);
941 + else delete_transient($site_rate_key);
942 + $this->release_recovery_lock($rate_owner);
943 + wp_send_json_error(array('message' => 'Unable to process recovery request. Please try again later.'));
944 + return;
945 + }
946 +
947 + // Keep only a hash in the database; the raw token is sent once by email.
948 + $token = bin2hex(random_bytes(32));
949 + $token_hash = hash('sha256', $token);
950 + $reset_key = 'metasync_pw_reset_' . $token_hash;
951 + if (!set_transient($reset_key, array('created_by' => $user_id), 30 * MINUTE_IN_SECONDS)) {
952 + if ($user_attempts > 0) set_transient($user_rate_key, $user_attempts, HOUR_IN_SECONDS);
953 + else delete_transient($user_rate_key);
954 + if ($site_attempts > 0) set_transient($site_rate_key, $site_attempts, HOUR_IN_SECONDS);
955 + else delete_transient($site_rate_key);
956 + $this->release_recovery_lock($rate_owner);
957 + $rate_owner = '';
958 + wp_send_json_error(array('message' => 'Unable to create a recovery request. Please try again later.'));
959 + return;
960 + }
961 + $this->release_recovery_lock($rate_owner);
962 + $rate_owner = '';
963 +
876 964 $site_name = get_bloginfo('name');
877 965 $site_url = home_url();
878 966 $plugin_name = Metasync::get_effective_plugin_name('');
879 - $settings_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=whitelabel');
967 + $settings_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=whitelabel&metasync_password_reset=' . rawurlencode($token));
880 968 $to = $recovery_email;
881 969
882 970 $subject = sprintf('[%s] Settings Password Recovery', $site_name);
883 971
@@ -883,16 +971,14 @@
883 971
884 972 $message = sprintf(
885 973 "Hello,\n\n" .
886 974 "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" .
975 + "Use this one-time link to choose a new Settings Password (valid for 30 minutes):\n%s\n\n" .
889 976 "If you did not request this password recovery, please secure your WordPress admin account immediately.\n\n" .
890 977 "---\n" .
891 978 "This is an automated message from %s\n%s",
892 979 $plugin_name,
893 980 $site_name,
894 - $password,
895 981 $settings_url,
896 982 $site_name,
897 983 $site_url
898 984 );
@@ -905,13 +991,8 @@
905 991 'Content-Type: text/plain; charset=UTF-8',
906 992 sprintf('From: %s <%s>', $from_name, get_option('admin_email'))
907 993 );
908 994
909 - $mail_error = '';
910 - add_action('wp_mail_failed', function($error) use (&$mail_error) {
911 - $mail_error = $error->get_error_message();
912 - });
913 -
914 995 $sent = wp_mail($to, $subject, $message, $headers);
915 996
916 997 if ($sent) {
917 998 wp_send_json_success(array(
@@ -917,21 +998,19 @@
917 998 wp_send_json_success(array(
918 999 'message' => sprintf('Password recovery email sent to %s', esc_html($recovery_email))
919 1000 ));
920 1001 } 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));
1002 + wp_send_json_error(array('message' => 'Failed to send recovery email. Please check your email configuration or contact your administrator.'));
929 1003 }
930 1004
931 1005 } catch (Exception $e) {
1006 + // Always release the bounded issuance lock if an unexpected
1007 + // exception occurs after it was acquired.
1008 + if ($rate_owner !== '') {
1009 + $this->release_recovery_lock($rate_owner);
1010 + }
932 1011 if (defined('WP_DEBUG') && WP_DEBUG) {
933 - error_log(sprintf(
1012 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
934 1013 'MetaSync Password Recovery Error: %s in %s on line %d',
935 1014 $e->getMessage(),
936 1015 $e->getFile(),
937 1016 $e->getLine()
@@ -941,8 +1020,113 @@
941 1020 wp_send_json_error(array('message' => 'An error occurred while processing your request. Please try again later.'));
942 1021 }
943 1022 }
944 1023
1024 + /** Render and process the administrator-only password reset screen. */
1025 + public function render_password_reset_page()
1026 + {
1027 + if (!current_user_can('manage_options')) {
1028 + wp_die('You do not have permission to reset this password.', '', array('response' => 403));
1029 + }
1030 +
1031 + $token = isset($_REQUEST['metasync_password_reset']) ? (string) wp_unslash($_REQUEST['metasync_password_reset']) : '';
1032 + $token_hash = ($token !== '' && preg_match('/^[a-f0-9]{64}$/', $token)) ? hash('sha256', $token) : '';
1033 + $generation = (int) get_option('metasync_pw_reset_generation', 0);
1034 + $transient_key = $token_hash !== '' ? 'metasync_pw_reset_' . $token_hash : '';
1035 + $error = '';
1036 + $success = false;
1037 +
1038 + if ($_SERVER['REQUEST_METHOD'] === 'POST') {
1039 + if (!isset($_POST['metasync_reset_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['metasync_reset_nonce'])), 'metasync_reset_password')) {
1040 + $error = 'Security verification failed.';
1041 + } elseif ($token === '' || false === get_transient($transient_key)) {
1042 + $error = 'This recovery link is invalid or has expired.';
1043 + } else {
1044 + $password = isset($_POST['new_password']) ? (string) wp_unslash($_POST['new_password']) : '';
1045 + $confirm = isset($_POST['confirm_password']) ? (string) wp_unslash($_POST['confirm_password']) : '';
1046 + if (strlen($password) < 8) {
1047 + $error = 'Password must be at least 8 characters long.';
1048 + } elseif (!hash_equals($password, $confirm)) {
1049 + $error = 'Passwords do not match.';
1050 + } elseif (strpos($password, 'enc_v1:') === 0) {
1051 + $error = 'Unable to save this password. Please choose a different password.';
1052 + } else {
1053 + // add_option is an atomic insert in WordPress; it closes the
1054 + // race where two requests attempt to redeem the same token.
1055 + $lock_key = '';
1056 + if (!$this->acquire_recovery_lock($lock_key)) {
1057 + $error = 'Another recovery request is being processed. Please wait a moment and try again.';
1058 + } else {
1059 + register_shutdown_function(function () use (&$lock_key) {
1060 + if ($lock_key !== '') {
1061 + Metasync_Admin_Ajax::instance()->release_recovery_lock($lock_key);
1062 + }
1063 + });
1064 + $this->refresh_recovery_state_cache($transient_key);
1065 + // Refresh the settings version while holding the shared
1066 + // lock. Token keys are independent so issuing or using
1067 + // one link does not invalidate another unexpired link.
1068 + $generation = (int) get_option('metasync_pw_reset_generation', 0);
1069 + $transient_key = $token_hash !== '' ? 'metasync_pw_reset_' . $token_hash : '';
1070 + // Re-check after claiming the lock. Consume only after
1071 + // every required write succeeds, allowing a retry after
1072 + // a temporary database or revocation failure.
1073 + if (false === get_transient($transient_key)) {
1074 + $error = 'This recovery link is invalid or has already been used.';
1075 + $this->release_recovery_lock($lock_key);
1076 + $lock_key = '';
1077 + echo '<div class="wrap"><h1>Reset Settings Password</h1><div class="notice notice-error"><p>' . esc_html($error) . '</p></div></div>';
1078 + return;
1079 + }
1080 + {
1081 + $encrypted = Metasync::encrypt_secret($password);
1082 + if (!Metasync::is_encrypted_secret($encrypted) || $encrypted === $password) {
1083 + $error = 'Unable to securely save this password. Please try again later.';
1084 + } elseif (!update_option('metasync_pw_reset_generation', $generation + 1, false)) {
1085 + $error = 'Unable to prepare the password reset. Please try again later.';
1086 + /** @phpstan-ignore-next-line booleanNot.alwaysFalse */
1087 + } elseif (!Metasync_Auth_Manager::revoke_all_access('whitelabel')) {
1088 + $error = 'Unable to revoke existing access. Please try again later.';
1089 + } elseif (!$this->persist_recovery_password($encrypted)) {
1090 + $error = 'Unable to save the new password. Please try again later.';
1091 + /** @phpstan-ignore-next-line booleanNot.alwaysFalse */
1092 + } elseif (!Metasync_Auth_Manager::revoke_all_access('whitelabel')) {
1093 + $error = 'Unable to finalize access revocation. Please try again later.';
1094 + } elseif (!delete_transient($transient_key)) {
1095 + $error = 'Unable to consume this recovery link. Please try again later.';
1096 + } else {
1097 + $success = true;
1098 + }
1099 + }
1100 + $this->release_recovery_lock($lock_key);
1101 + $lock_key = '';
1102 + }
1103 + }
1104 + }
1105 + }
1106 +
1107 + echo '<div class="wrap"><h1>Reset Settings Password</h1>';
1108 + if ($success) {
1109 + echo '<div class="notice notice-success"><p>Password reset successfully. You can now return to the White Label settings.</p></div>';
1110 + } else {
1111 + if ($error !== '') {
1112 + echo '<div class="notice notice-error"><p>' . esc_html($error) . '</p></div>';
1113 + }
1114 + if ($token === '' || false === get_transient($transient_key)) {
1115 + if ($error === '') {
1116 + echo '<div class="notice notice-error"><p>This recovery link is invalid or has expired.</p></div>';
1117 + }
1118 + } else {
1119 + echo '<form method="post" style="max-width:420px">';
1120 + wp_nonce_field('metasync_reset_password', 'metasync_reset_nonce');
1121 + 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>';
1122 + 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>';
1123 + echo '<p><button class="button button-primary" type="submit">Save new password</button></p></form>';
1124 + }
1125 + }
1126 + echo '</div>';
1127 + }
1128 +
945 1129 public function ajax_save_theme()
946 1130 {
947 1131 try {
948 1132 # Verify nonce for security
@@ -947,15 +1131,13 @@
947 1131 try {
948 1132 # Verify nonce for security
949 1133 if (!isset($_POST['_ajax_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_ajax_nonce'])), 'metasync_theme_nonce')) {
950 1134 wp_send_json_error(array('message' => 'Security verification failed.'));
951 - return;
952 1135 }
953 1136
954 1137 # Check user capabilities
955 1138 if (!Metasync::current_user_has_plugin_access()) {
956 1139 wp_send_json_error(array('message' => 'Insufficient permissions.'));
957 - return;
958 1140 }
959 1141
960 1142 # Get and validate theme value
961 1143 $theme = isset($_POST['theme']) ? sanitize_text_field(wp_unslash($_POST['theme'])) : '';
@@ -962,9 +1144,8 @@
962 1144
963 1145 # Validate theme is either 'light' or 'dark'
964 1146 if (!in_array($theme, array('light', 'dark'), true)) {
965 1147 wp_send_json_error(array('message' => 'Invalid theme value.'));
966 - return;
967 1148 }
968 1149
969 1150 # Save theme preference to WordPress options
970 1151 update_option('metasync_theme', $theme, true);
@@ -977,9 +1158,9 @@
977 1158
978 1159 } catch (Exception $e) {
979 1160 # Log error if debug is enabled
980 1161 if (defined('WP_DEBUG') && WP_DEBUG) {
981 - error_log('MetaSync Theme Save Error: ' . $e->getMessage());
1162 + error_log('MetaSync Theme Save Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
982 1163 }
983 1164
984 1165 wp_send_json_error(array('message' => 'Failed to save theme preference.'));
985 1166 }
@@ -984,14 +1165,14 @@
984 1165 wp_send_json_error(array('message' => 'Failed to save theme preference.'));
985 1166 }
986 1167 }
987 1168
988 - public function ajax_track_one_click_activation()
1169 + public function ajax_track_one_click_activation()
989 1170 {
1171 + check_ajax_referer('metasync_nonce', 'nonce');
990 1172 # Check user capabilities
991 1173 if (!Metasync::current_user_has_plugin_access()) {
992 1174 wp_send_json_error(['message' => 'Insufficient permissions']);
993 - return;
994 1175 }
995 1176
996 1177 # Get parameters
997 1178 $auth_method = isset($_POST['auth_method']) ? sanitize_text_field(wp_unslash($_POST['auth_method'])) : 'searchatlas_connect';
@@ -1035,9 +1216,17 @@
1035 1216
1036 1217 try {
1037 1218 # Get all whitelabel settings
1038 1219 $whitelabel_settings = Metasync::get_whitelabel_settings();
1039 -
1220 +
1221 + # Export the settings password as plaintext (not the encrypted blob):
1222 + # the encryption key derives from this site's salts, so the blob
1223 + # would be undecryptable after import on a different site. The
1224 + # importer re-encrypts it at rest with the destination site's salts.
1225 + if (!empty($whitelabel_settings['settings_password'])) {
1226 + $whitelabel_settings['settings_password'] = Metasync::get_whitelabel_password();
1227 + }
1228 +
1040 1229 # Get general settings that relate to whitelabel
1041 1230 $general_settings = Metasync::get_option('general');
1042 1231 $whitelabel_related_general = array();
1043 1232
@@ -1112,9 +1301,9 @@
1112 1301 wp_mkdir_p($temp_dir);
1113 1302 }
1114 1303
1115 1304 # Generate unique filename
1116 - $timestamp = date('Y-m-d_H-i-s');
1305 + $timestamp = gmdate('Y-m-d_H-i-s');
1117 1306 $zip_filename = 'metasync-whitelabel-plugin-' . $timestamp . '.zip';
1118 1307 $json_filename = 'whitelabel-settings.json';
1119 1308 $zip_path = $temp_dir . '/' . $zip_filename;
1120 1309
@@ -1175,14 +1364,14 @@
1175 1364 header('Pragma: no-cache');
1176 1365 header('Expires: 0');
1177 1366
1178 1367 # Output file and clean up
1179 - readfile($zip_path);
1180 - unlink($zip_path);
1368 + readfile($zip_path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streams a large file to output without loading it into memory
1369 + wp_delete_file($zip_path);
1181 1370
1182 1371 # Clean up temp directory if empty
1183 1372 if (is_dir($temp_dir) && count(scandir($temp_dir)) == 2) {
1184 - rmdir($temp_dir);
1373 + rmdir($temp_dir); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- WP_Filesystem would prompt for FTP credentials on non-direct hosts
1185 1374 }
1186 1375
1187 1376 # Exit to prevent WordPress from adding anything to the response
1188 1377 exit;
@@ -1189,12 +1378,12 @@
1189 1378
1190 1379 } catch (Exception $e) {
1191 1380 # Log error if debug is enabled
1192 1381 if (defined('WP_DEBUG') && WP_DEBUG) {
1193 - error_log('MetaSync Whitelabel Export Error: ' . $e->getMessage());
1382 + error_log('MetaSync Whitelabel Export Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
1194 1383 }
1195 1384
1196 - wp_die('Failed to export whitelabel settings: ' . $e->getMessage());
1385 + wp_die(esc_html__('Failed to export whitelabel settings.', 'metasync'));
1197 1386 }
1198 1387 }
1199 1388
1200 1389 private static function add_directory_to_zip($zip, $dir, $zip_path = '', $exclude_patterns = array())
@@ -1239,10 +1428,10 @@
1239 1428 public function render_html_pages_dashboard_widget()
1240 1429 {
1241 1430 global $wpdb;
1242 1431
1243 - $query = "
1244 - SELECT p.ID, p.post_title, p.post_modified, p.post_type
1432 + $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
1433 + "SELECT p.ID, p.post_title, p.post_modified, p.post_type
1245 1434 FROM {$wpdb->posts} p
1246 1435 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
1247 1436 WHERE p.post_status = 'publish'
1248 1437 AND p.post_type IN ('post', 'page')
@@ -1249,11 +1438,10 @@
1249 1438 AND (pm.meta_key = '_metasync_raw_html_enabled' OR pm.meta_key = '_metasync_custom_css')
1250 1439 GROUP BY p.ID
1251 1440 ORDER BY p.post_modified DESC
1252 1441 LIMIT 10
1253 - ";
1254 -
1255 - $html_pages = $wpdb->get_results($query);
1442 + "
1443 + );
1256 1444 $total_count = count($html_pages);
1257 1445
1258 1446 $label = $this->get_html_source_label();
1259 1447
@@ -1260,13 +1448,14 @@
1260 1448 if (empty($html_pages)) {
1261 1449 echo '<div class="metasync-dashboard-widget-empty">';
1262 1450 echo '<span class="dashicons dashicons-admin-page" style="font-size: 48px; opacity: 0.3; display: block; margin: 20px auto;"></span>';
1263 1451 echo '<p style="text-align: center; color: #666;">';
1452 + /* translators: %s: HTML source label. */
1264 1453 echo sprintf(__('No pages created with %s yet.', 'metasync'), '<strong>' . esc_html($label) . '</strong>');
1265 1454 echo '</p>';
1266 1455 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');
1456 + echo '<a href="' . esc_url(admin_url('admin.php?page=' . Metasync_Admin::$page_slug)) . '" class="button button-primary">';
1457 + echo esc_html__('Get Started', 'metasync');
1269 1458 echo '</a>';
1270 1459 echo '</p>';
1271 1460 echo '</div>';
1272 1461 return;
@@ -1275,15 +1464,15 @@
1275 1464 echo '<div class="metasync-dashboard-widget">';
1276 1465
1277 1466 echo '<div class="metasync-widget-stats">';
1278 1467 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>';
1468 + echo '<span class="metasync-stat-number">' . esc_html((string) $total_count) . '</span>';
1469 + echo '<span class="metasync-stat-label">' . esc_html__('AI-Generated Pages', 'metasync') . '</span>';
1281 1470 echo '</div>';
1282 1471 echo '</div>';
1283 1472
1284 1473 echo '<div class="metasync-widget-list">';
1285 - echo '<h4>' . __('Recent Pages', 'metasync') . '</h4>';
1474 + echo '<h4>' . esc_html__('Recent Pages', 'metasync') . '</h4>';
1286 1475 echo '<ul>';
1287 1476
1288 1477 foreach ($html_pages as $page) {
1289 1478 $edit_link = get_edit_post_link($page->ID);
@@ -1296,11 +1485,12 @@
1296 1485 echo '<a href="' . esc_url($edit_link) . '" class="metasync-page-title">';
1297 1486 echo esc_html($page->post_title ?: __('(no title)', 'metasync'));
1298 1487 echo '</a>';
1299 1488 echo '<span class="metasync-page-meta">';
1300 - echo sprintf(__('Updated %s ago', 'metasync'), $time_ago);
1489 + /* translators: %s: human-readable time difference. */
1490 + echo esc_html(sprintf(__('Updated %s ago', 'metasync'), $time_ago));
1301 1491 echo ' • ';
1302 - echo '<a href="' . esc_url($view_link) . '" target="_blank">' . __('View', 'metasync') . '</a>';
1492 + echo '<a href="' . esc_url($view_link) . '" target="_blank">' . esc_html__('View', 'metasync') . '</a>';
1303 1493 echo '</span>';
1304 1494 echo '</div>';
1305 1495 echo '</li>';
1306 1496 }
@@ -1308,10 +1498,10 @@
1308 1498 echo '</ul>';
1309 1499 echo '</div>';
1310 1500
1311 1501 echo '<div class="metasync-widget-footer">';
1312 - echo '<a href="' . admin_url('edit.php?post_type=page') . '">';
1313 - echo __('View All Pages', 'metasync') . ' →';
1502 + echo '<a href="' . esc_url(admin_url("edit.php?post_type=page")) . '">';
1503 + echo esc_html__('View All Pages', 'metasync') . ' →';
1314 1504 echo '</a>';
1315 1505 echo '</div>';
1316 1506
1317 1507 echo '</div>';