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 +401 -232 2.6.7 → 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 ];
@@ -135,10 +237,14 @@
135 237 # use the existing apikey for backward compatibility
136 238 $general_options = Metasync::get_option('general') ?? [];
137 239 $token = $general_options['apikey'] ?? null;
138 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 +
139 245 # get the response
140 - $response = $sync_request->SyncCustomerParams($token);
246 + $response = $sync_request->SyncCustomerParams($token, $is_heartbeat_tick ? 'heartbeat' : 'manual');
141 247
142 248 // Check if response is a throttling error object
143 249 if (is_object($response) && isset($response->throttled) && $response->throttled === true) {
144 250 wp_send_json($response);
@@ -154,11 +260,14 @@
154 260 $responseBody = wp_remote_retrieve_body($response);
155 261 $responseCode = wp_remote_retrieve_response_code($response);
156 262
157 263 if ($responseCode == 200) {
158 - $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.
159 268 $send_auth_token_timestamp = Metasync::get_option();
160 - $send_auth_token_timestamp['general']['send_auth_token_timestamp'] = $dt->format('M d, Y h:i:s A');;
269 + $send_auth_token_timestamp['general']['send_auth_token_timestamp'] = current_time('mysql');
161 270 Metasync::set_option($send_auth_token_timestamp);
162 271
163 272 Metasync_Heartbeat_Manager::instance()->update_heartbeat_cache_after_sync(true, 'Sync Now - successful data sync');
164 273
@@ -328,9 +437,9 @@
328 437 }
329 438
330 439 wp_send_json_success(array(
331 440 'content' => $backup['content'],
332 - 'created_at' => $backup['created_at'],
441 + 'created_at' => get_date_from_gmt($backup['created_at'], get_option('date_format') . ' ' . get_option('time_format')),
333 442 'created_by_name' => isset($backup['created_by_name']) ? $backup['created_by_name'] : ''
334 443 ));
335 444 }
336 445
@@ -404,8 +513,48 @@
404 513 }
405 514 }
406 515 }
407 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 +
408 557 public function ajax_create_redirect_from_404()
409 558 {
410 559 if (empty($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_404_redirect')) {
411 560 wp_die('Security check failed');
@@ -414,10 +563,10 @@
414 563 if (!Metasync::current_user_has_plugin_access()) {
415 564 wp_die('Insufficient permissions');
416 565 }
417 566
418 - $uri = sanitize_text_field($_POST['uri']);
419 - $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']));
420 569
421 570 if (empty($uri) || empty($redirect_url)) {
422 571 wp_send_json_error('Missing required parameters');
423 572 }
@@ -435,91 +584,33 @@
435 584 wp_send_json_error('Failed to create redirect');
436 585 }
437 586 }
438 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 + */
439 594 public function ajax_test_host_blocking_get()
440 595 {
441 - check_ajax_referer('metasync_nonce', 'nonce');
442 - if (!Metasync::current_user_has_plugin_access()) {
443 - wp_send_json_error('Insufficient permissions');
444 - return;
445 - }
596 + $this->send_host_blocking_result('GET');
597 + }
446 598
447 - $endpoint = 'https://wp-check.searchatlas.com/ping';
448 - $start_time = microtime(true);
449 -
450 - $response = wp_remote_get($endpoint, array(
451 - 'timeout' => 30,
452 - 'user-agent' => 'MetaSync Plugin Host Test',
453 - 'headers' => array(
454 - 'Accept' => 'application/json',
455 - 'Content-Type' => 'application/json',
456 - 'Origin' => home_url(),
457 - 'Referer' => admin_url(),
458 - 'X-WordPress-Site' => home_url()
459 - )
460 - ));
461 -
462 - $end_time = microtime(true);
463 - $response_time = round(($end_time - $start_time) * 1000, 2);
464 -
465 - if (is_wp_error($response)) {
466 - wp_send_json_success(array(
467 - 'method' => 'GET',
468 - 'status' => 'error',
469 - 'response_time' => $response_time . 'ms',
470 - 'error' => $response->get_error_message(),
471 - 'blocked' => true,
472 - 'details' => 'Request failed - possible blocking detected'
473 - ));
474 - } else {
475 - $status_code = wp_remote_retrieve_response_code($response);
476 - $body = wp_remote_retrieve_body($response);
477 - $headers = wp_remote_retrieve_headers($response);
478 -
479 - $is_blocked = false;
480 - $status_text = 'success';
481 - $details = 'GET request completed successfully';
482 - $parsed_response = null;
483 -
484 - if ($status_code === 200) {
485 - $parsed_response = json_decode($body, true);
486 -
487 - if ($parsed_response && isset($parsed_response['results']['get'])) {
488 - $get_result = $parsed_response['results']['get'];
489 - $get_status_code = isset($get_result['statusCode']) ? $get_result['statusCode'] : null;
490 -
491 - if ($get_status_code !== 200) {
492 - $is_blocked = true;
493 - $status_text = 'error';
494 - $details = "GET request to target site returned status code {$get_status_code} - host blocking detected";
495 - }
496 - } else {
497 - $is_blocked = true;
498 - $status_text = 'error';
499 - $details = 'Unable to parse response structure - possible blocking or endpoint issue';
500 - }
501 - } else {
502 - $is_blocked = true;
503 - $status_text = 'error';
504 - $details = "External endpoint returned status code {$status_code} - possible blocking detected";
505 - }
506 -
507 - wp_send_json_success(array(
508 - 'method' => 'GET',
509 - 'status' => $status_text,
510 - 'response_time' => $response_time . 'ms',
511 - 'status_code' => $status_code,
512 - 'body' => $body,
513 - 'headers' => $headers->getAll(),
514 - 'blocked' => $is_blocked,
515 - 'details' => $details,
516 - 'parsed_response' => $parsed_response
517 - ));
518 - }
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');
519 605 }
520 606
521 - 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)
522 613 {
523 614 check_ajax_referer('metasync_nonce', 'nonce');
524 615 if (!Metasync::current_user_has_plugin_access()) {
525 616 wp_send_json_error('Insufficient permissions');
@@ -525,90 +616,9 @@
525 616 wp_send_json_error('Insufficient permissions');
526 617 return;
527 618 }
528 619
529 - $endpoint = 'https://wp-check.searchatlas.com/ping';
530 - $start_time = microtime(true);
531 -
532 - $test_data = array(
533 - 'test' => 'host_blocking_test',
534 - 'timestamp' => current_time('mysql'),
535 - 'source' => 'metasync_plugin',
536 - 'method' => 'POST'
537 - );
538 -
539 - $response = wp_remote_post($endpoint, array(
540 - 'timeout' => 30,
541 - 'user-agent' => 'MetaSync Plugin Host Test',
542 - 'headers' => array(
543 - 'Accept' => 'application/json',
544 - 'Content-Type' => 'application/json',
545 - 'Origin' => home_url(),
546 - 'Referer' => admin_url(),
547 - 'X-WordPress-Site' => home_url()
548 - ),
549 - 'body' => json_encode($test_data)
550 - ));
551 -
552 - $end_time = microtime(true);
553 - $response_time = round(($end_time - $start_time) * 1000, 2);
554 -
555 - if (is_wp_error($response)) {
556 - wp_send_json_success(array(
557 - 'method' => 'POST',
558 - 'status' => 'error',
559 - 'response_time' => $response_time . 'ms',
560 - 'error' => $response->get_error_message(),
561 - 'blocked' => true,
562 - 'details' => 'Request failed - possible blocking detected',
563 - 'sent_data' => $test_data
564 - ));
565 - } else {
566 - $status_code = wp_remote_retrieve_response_code($response);
567 - $body = wp_remote_retrieve_body($response);
568 - $headers = wp_remote_retrieve_headers($response);
569 -
570 - $is_blocked = false;
571 - $status_text = 'success';
572 - $details = 'POST request completed successfully';
573 - $parsed_response = null;
574 -
575 - if ($status_code === 200) {
576 - $parsed_response = json_decode($body, true);
577 -
578 - if ($parsed_response && isset($parsed_response['results']['post'])) {
579 - $post_result = $parsed_response['results']['post'];
580 - $post_status_code = isset($post_result['statusCode']) ? $post_result['statusCode'] : null;
581 -
582 - if ($post_status_code !== 200) {
583 - $is_blocked = true;
584 - $status_text = 'error';
585 - $details = "POST request to target site returned status code {$post_status_code} - host blocking detected";
586 - }
587 - } else {
588 - $is_blocked = true;
589 - $status_text = 'error';
590 - $details = 'Unable to parse response structure - possible blocking or endpoint issue';
591 - }
592 - } else {
593 - $is_blocked = true;
594 - $status_text = 'error';
595 - $details = "External endpoint returned status code {$status_code} - possible blocking detected";
596 - }
597 -
598 - wp_send_json_success(array(
599 - 'method' => 'POST',
600 - 'status' => $status_text,
601 - 'response_time' => $response_time . 'ms',
602 - 'status_code' => $status_code,
603 - 'body' => $body,
604 - 'headers' => $headers->getAll(),
605 - 'blocked' => $is_blocked,
606 - 'details' => $details,
607 - 'sent_data' => $test_data,
608 - 'parsed_response' => $parsed_response
609 - ));
610 - }
620 + wp_send_json_success(Metasync_Host_Blocking_Check::get_instance()->run_check($method));
611 621 }
612 622
613 623 public function execute_transient_cleanup()
614 624 {
@@ -625,9 +635,9 @@
625 635 try {
626 636 delete_expired_transients(true);
627 637 $cleanup_stats['expired_transients'] = 'cleaned_by_wordpress';
628 638
629 - $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
630 640 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_%'",
631 641 ARRAY_A
632 642 );
633 643
@@ -636,9 +646,9 @@
636 646 delete_transient($transient_name);
637 647 $cleanup_stats['plugin_transients']++;
638 648 }
639 649
640 - $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
641 651 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
642 652 ARRAY_A
643 653 );
644 654
@@ -647,9 +657,9 @@
647 657 delete_transient($transient_name);
648 658 $cleanup_stats['rate_limit_transients']++;
649 659 }
650 660
651 - $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
652 662 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_telemetry_%'",
653 663 ARRAY_A
654 664 );
655 665
@@ -658,9 +668,9 @@
658 668 delete_transient($transient_name);
659 669 $cleanup_stats['telemetry_transients']++;
660 670 }
661 671
662 - $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
663 673 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_sa_connect_success_%'",
664 674 ARRAY_A
665 675 );
666 676
@@ -671,14 +681,12 @@
671 681 }
672 682
673 683 $cleanup_stats['execution_time'] = round((microtime(true) - $cleanup_stats['start_time']) * 1000, 2);
674 684 $cleanup_stats['next_run'] = wp_next_scheduled('metasync_cleanup_transients') ?
675 - date('Y-m-d H:i:s T', wp_next_scheduled('metasync_cleanup_transients')) : 'N/A';
685 + gmdate('Y-m-d H:i:s T', wp_next_scheduled('metasync_cleanup_transients')) : 'N/A';
676 686
677 - error_log('MetaSync: Transient cleanup completed - ' . json_encode($cleanup_stats));
678 -
679 687 } catch (Exception $e) {
680 - 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
681 689 }
682 690 }
683 691
684 692 public function ajax_submit_issue_report()
@@ -808,9 +816,9 @@
808 816 # Check if Sentry feedback function exists
809 817 if (!function_exists('metasync_sentry_capture_feedback')) {
810 818 # Log warning if function doesn't exist
811 819 if (defined('WP_DEBUG') && WP_DEBUG) {
812 - 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
813 821 }
814 822 } else {
815 823 $feedback_data = array(
816 824 'message' => $issue_message,
@@ -837,13 +845,11 @@
837 845 ));
838 846 } else {
839 847 # Fallback: Log locally if Sentry fails or is unavailable
840 848 if (defined('WP_DEBUG') && WP_DEBUG) {
841 - error_log(sprintf(
842 - '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',
843 851 $project_uuid,
844 - $issue_title,
845 - $issue_message,
846 852 $issue_severity
847 853 ));
848 854 }
849 855
@@ -857,9 +863,9 @@
857 863
858 864 } catch (Exception $e) {
859 865 # Log the error securely (only in debug mode)
860 866 if (defined('WP_DEBUG') && WP_DEBUG) {
861 - error_log(sprintf(
867 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
862 868 'MetaSync Report Submission Error: %s in %s on line %d',
863 869 $e->getMessage(),
864 870 $e->getFile(),
865 871 $e->getLine()
@@ -872,8 +878,9 @@
872 878 }
873 879
874 880 public function ajax_recover_password()
875 881 {
882 + $rate_owner = '';
876 883 try {
877 884 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_recover_password_nonce')) {
878 885 wp_send_json_error(array('message' => 'Security verification failed.'));
879 886 return;
@@ -878,26 +885,87 @@
878 885 wp_send_json_error(array('message' => 'Security verification failed.'));
879 886 return;
880 887 }
881 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 +
882 905 $whitelabel_settings = Metasync::get_whitelabel_settings();
883 - $password = $whitelabel_settings['settings_password'] ?? '';
884 906 $recovery_email = $whitelabel_settings['recovery_email'] ?? '';
885 907
886 - 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 = '';
887 912 wp_send_json_error(array('message' => 'No password is configured for recovery.'));
888 913 return;
889 914 }
890 915
891 916 if (empty($recovery_email) || !is_email($recovery_email)) {
917 + $this->release_recovery_lock($rate_owner);
918 + $rate_owner = '';
892 919 wp_send_json_error(array('message' => 'No valid recovery email is configured. Please contact your administrator.'));
893 920 return;
894 921 }
895 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 +
896 964 $site_name = get_bloginfo('name');
897 965 $site_url = home_url();
898 966 $plugin_name = Metasync::get_effective_plugin_name('');
899 - $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));
900 968 $to = $recovery_email;
901 969
902 970 $subject = sprintf('[%s] Settings Password Recovery', $site_name);
903 971
@@ -903,16 +971,14 @@
903 971
904 972 $message = sprintf(
905 973 "Hello,\n\n" .
906 974 "A password recovery request was made for the %s settings on %s.\n\n" .
907 - "Your Settings Password is:\n%s\n\n" .
908 - "You can use this password to access the protected settings at:\n%s\n\n" .
975 + "Use this one-time link to choose a new Settings Password (valid for 30 minutes):\n%s\n\n" .
909 976 "If you did not request this password recovery, please secure your WordPress admin account immediately.\n\n" .
910 977 "---\n" .
911 978 "This is an automated message from %s\n%s",
912 979 $plugin_name,
913 980 $site_name,
914 - $password,
915 981 $settings_url,
916 982 $site_name,
917 983 $site_url
918 984 );
@@ -925,13 +991,8 @@
925 991 'Content-Type: text/plain; charset=UTF-8',
926 992 sprintf('From: %s <%s>', $from_name, get_option('admin_email'))
927 993 );
928 994
929 - $mail_error = '';
930 - add_action('wp_mail_failed', function($error) use (&$mail_error) {
931 - $mail_error = $error->get_error_message();
932 - });
933 -
934 995 $sent = wp_mail($to, $subject, $message, $headers);
935 996
936 997 if ($sent) {
937 998 wp_send_json_success(array(
@@ -937,21 +998,19 @@
937 998 wp_send_json_success(array(
938 999 'message' => sprintf('Password recovery email sent to %s', esc_html($recovery_email))
939 1000 ));
940 1001 } else {
941 - $error_message = 'Failed to send recovery email. ';
942 - if (!empty($mail_error)) {
943 - $error_message .= 'Error: ' . $mail_error;
944 - } else {
945 - $error_message .= 'Your server may not be configured to send emails. Please check your email configuration or contact your administrator.';
946 - }
947 -
948 - wp_send_json_error(array('message' => $error_message));
1002 + wp_send_json_error(array('message' => 'Failed to send recovery email. Please check your email configuration or contact your administrator.'));
949 1003 }
950 1004
951 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 + }
952 1011 if (defined('WP_DEBUG') && WP_DEBUG) {
953 - error_log(sprintf(
1012 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
954 1013 'MetaSync Password Recovery Error: %s in %s on line %d',
955 1014 $e->getMessage(),
956 1015 $e->getFile(),
957 1016 $e->getLine()
@@ -961,8 +1020,113 @@
961 1020 wp_send_json_error(array('message' => 'An error occurred while processing your request. Please try again later.'));
962 1021 }
963 1022 }
964 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 +
965 1129 public function ajax_save_theme()
966 1130 {
967 1131 try {
968 1132 # Verify nonce for security
@@ -967,15 +1131,13 @@
967 1131 try {
968 1132 # Verify nonce for security
969 1133 if (!isset($_POST['_ajax_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_ajax_nonce'])), 'metasync_theme_nonce')) {
970 1134 wp_send_json_error(array('message' => 'Security verification failed.'));
971 - return;
972 1135 }
973 1136
974 1137 # Check user capabilities
975 1138 if (!Metasync::current_user_has_plugin_access()) {
976 1139 wp_send_json_error(array('message' => 'Insufficient permissions.'));
977 - return;
978 1140 }
979 1141
980 1142 # Get and validate theme value
981 1143 $theme = isset($_POST['theme']) ? sanitize_text_field(wp_unslash($_POST['theme'])) : '';
@@ -982,9 +1144,8 @@
982 1144
983 1145 # Validate theme is either 'light' or 'dark'
984 1146 if (!in_array($theme, array('light', 'dark'), true)) {
985 1147 wp_send_json_error(array('message' => 'Invalid theme value.'));
986 - return;
987 1148 }
988 1149
989 1150 # Save theme preference to WordPress options
990 1151 update_option('metasync_theme', $theme, true);
@@ -997,9 +1158,9 @@
997 1158
998 1159 } catch (Exception $e) {
999 1160 # Log error if debug is enabled
1000 1161 if (defined('WP_DEBUG') && WP_DEBUG) {
1001 - 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
1002 1163 }
1003 1164
1004 1165 wp_send_json_error(array('message' => 'Failed to save theme preference.'));
1005 1166 }
@@ -1010,9 +1171,8 @@
1010 1171 check_ajax_referer('metasync_nonce', 'nonce');
1011 1172 # Check user capabilities
1012 1173 if (!Metasync::current_user_has_plugin_access()) {
1013 1174 wp_send_json_error(['message' => 'Insufficient permissions']);
1014 - return;
1015 1175 }
1016 1176
1017 1177 # Get parameters
1018 1178 $auth_method = isset($_POST['auth_method']) ? sanitize_text_field(wp_unslash($_POST['auth_method'])) : 'searchatlas_connect';
@@ -1056,9 +1216,17 @@
1056 1216
1057 1217 try {
1058 1218 # Get all whitelabel settings
1059 1219 $whitelabel_settings = Metasync::get_whitelabel_settings();
1060 -
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 +
1061 1229 # Get general settings that relate to whitelabel
1062 1230 $general_settings = Metasync::get_option('general');
1063 1231 $whitelabel_related_general = array();
1064 1232
@@ -1133,9 +1301,9 @@
1133 1301 wp_mkdir_p($temp_dir);
1134 1302 }
1135 1303
1136 1304 # Generate unique filename
1137 - $timestamp = date('Y-m-d_H-i-s');
1305 + $timestamp = gmdate('Y-m-d_H-i-s');
1138 1306 $zip_filename = 'metasync-whitelabel-plugin-' . $timestamp . '.zip';
1139 1307 $json_filename = 'whitelabel-settings.json';
1140 1308 $zip_path = $temp_dir . '/' . $zip_filename;
1141 1309
@@ -1196,14 +1364,14 @@
1196 1364 header('Pragma: no-cache');
1197 1365 header('Expires: 0');
1198 1366
1199 1367 # Output file and clean up
1200 - readfile($zip_path);
1201 - 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);
1202 1370
1203 1371 # Clean up temp directory if empty
1204 1372 if (is_dir($temp_dir) && count(scandir($temp_dir)) == 2) {
1205 - 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
1206 1374 }
1207 1375
1208 1376 # Exit to prevent WordPress from adding anything to the response
1209 1377 exit;
@@ -1210,12 +1378,12 @@
1210 1378
1211 1379 } catch (Exception $e) {
1212 1380 # Log error if debug is enabled
1213 1381 if (defined('WP_DEBUG') && WP_DEBUG) {
1214 - 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
1215 1383 }
1216 1384
1217 - wp_die('Failed to export whitelabel settings: ' . $e->getMessage());
1385 + wp_die(esc_html__('Failed to export whitelabel settings.', 'metasync'));
1218 1386 }
1219 1387 }
1220 1388
1221 1389 private static function add_directory_to_zip($zip, $dir, $zip_path = '', $exclude_patterns = array())
@@ -1260,10 +1428,10 @@
1260 1428 public function render_html_pages_dashboard_widget()
1261 1429 {
1262 1430 global $wpdb;
1263 1431
1264 - $query = "
1265 - 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
1266 1434 FROM {$wpdb->posts} p
1267 1435 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
1268 1436 WHERE p.post_status = 'publish'
1269 1437 AND p.post_type IN ('post', 'page')
@@ -1270,11 +1438,10 @@
1270 1438 AND (pm.meta_key = '_metasync_raw_html_enabled' OR pm.meta_key = '_metasync_custom_css')
1271 1439 GROUP BY p.ID
1272 1440 ORDER BY p.post_modified DESC
1273 1441 LIMIT 10
1274 - ";
1275 -
1276 - $html_pages = $wpdb->get_results($query);
1442 + "
1443 + );
1277 1444 $total_count = count($html_pages);
1278 1445
1279 1446 $label = $this->get_html_source_label();
1280 1447
@@ -1281,13 +1448,14 @@
1281 1448 if (empty($html_pages)) {
1282 1449 echo '<div class="metasync-dashboard-widget-empty">';
1283 1450 echo '<span class="dashicons dashicons-admin-page" style="font-size: 48px; opacity: 0.3; display: block; margin: 20px auto;"></span>';
1284 1451 echo '<p style="text-align: center; color: #666;">';
1452 + /* translators: %s: HTML source label. */
1285 1453 echo sprintf(__('No pages created with %s yet.', 'metasync'), '<strong>' . esc_html($label) . '</strong>');
1286 1454 echo '</p>';
1287 1455 echo '<p style="text-align: center;">';
1288 - echo '<a href="' . admin_url('admin.php?page=' . Metasync_Admin::$page_slug) . '" class="button button-primary">';
1289 - echo __('Get Started', 'metasync');
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');
1290 1458 echo '</a>';
1291 1459 echo '</p>';
1292 1460 echo '</div>';
1293 1461 return;
@@ -1296,15 +1464,15 @@
1296 1464 echo '<div class="metasync-dashboard-widget">';
1297 1465
1298 1466 echo '<div class="metasync-widget-stats">';
1299 1467 echo '<div class="metasync-stat-box">';
1300 - echo '<span class="metasync-stat-number">' . $total_count . '</span>';
1301 - echo '<span class="metasync-stat-label">' . __('AI-Generated Pages', 'metasync') . '</span>';
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>';
1302 1470 echo '</div>';
1303 1471 echo '</div>';
1304 1472
1305 1473 echo '<div class="metasync-widget-list">';
1306 - echo '<h4>' . __('Recent Pages', 'metasync') . '</h4>';
1474 + echo '<h4>' . esc_html__('Recent Pages', 'metasync') . '</h4>';
1307 1475 echo '<ul>';
1308 1476
1309 1477 foreach ($html_pages as $page) {
1310 1478 $edit_link = get_edit_post_link($page->ID);
@@ -1317,11 +1485,12 @@
1317 1485 echo '<a href="' . esc_url($edit_link) . '" class="metasync-page-title">';
1318 1486 echo esc_html($page->post_title ?: __('(no title)', 'metasync'));
1319 1487 echo '</a>';
1320 1488 echo '<span class="metasync-page-meta">';
1321 - 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));
1322 1491 echo ' • ';
1323 - 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>';
1324 1493 echo '</span>';
1325 1494 echo '</div>';
1326 1495 echo '</li>';
1327 1496 }
@@ -1329,10 +1498,10 @@
1329 1498 echo '</ul>';
1330 1499 echo '</div>';
1331 1500
1332 1501 echo '<div class="metasync-widget-footer">';
1333 - echo '<a href="' . admin_url('edit.php?post_type=page') . '">';
1334 - 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') . ' →';
1335 1504 echo '</a>';
1336 1505 echo '</div>';
1337 1506
1338 1507 echo '</div>';