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 +385 -228 2.6.15 → 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,10 @@
41 138 if (!Metasync::current_user_has_plugin_access()) {
42 139 wp_send_json_error(['message' => 'Insufficient permissions.']);
43 140 }
44 141
45 - $type = isset($_POST['type']) ? sanitize_text_field($_POST['type']) : '';
46 - $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : '';
142 + $type = isset($_POST['type']) ? sanitize_text_field(wp_unslash($_POST['type'])) : '';
143 + $plugin = isset($_POST['plugin']) ? sanitize_text_field(wp_unslash($_POST['plugin'])) : '';
47 144 $offset = isset($_POST['offset']) ? intval($_POST['offset']) : 0;
48 145
49 146 if (empty($type) || empty($plugin)) {
50 147 wp_send_json_error(['message' => 'Missing required parameters.']);
@@ -82,9 +179,9 @@
82 179 public function ajax_import_seo_metadata()
83 180 {
84 181 $execution_time = Metasync_Settings_Fields::instance()->get_execution_setting('max_execution_time');
85 182 if (function_exists('set_time_limit')) {
86 - @set_time_limit($execution_time);
183 + @set_time_limit($execution_time); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- runtime PHP setting with no WordPress API
87 184 }
88 185
89 186 Metasync_Settings_Fields::instance()->apply_memory_limit();
90 187
@@ -93,9 +190,9 @@
93 190 if (!Metasync::current_user_has_plugin_access()) {
94 191 wp_send_json_error(['message' => 'Insufficient permissions.']);
95 192 }
96 193
97 - $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : '';
194 + $plugin = isset($_POST['plugin']) ? sanitize_text_field(wp_unslash($_POST['plugin'])) : '';
98 195 $import_titles = isset($_POST['import_titles']) ? (bool) intval($_POST['import_titles']) : true;
99 196 $import_descriptions = isset($_POST['import_descriptions']) ? (bool) intval($_POST['import_descriptions']) : true;
100 197 $import_social_text = isset($_POST['import_social_text']) ? (bool) intval($_POST['import_social_text']) : true;
101 198 $import_social_images = isset($_POST['import_social_images']) ? (bool) intval($_POST['import_social_images']) : true;
@@ -140,9 +237,9 @@
140 237 # use the existing apikey for backward compatibility
141 238 $general_options = Metasync::get_option('general') ?? [];
142 239 $token = $general_options['apikey'] ?? null;
143 240
144 - # WP-426: declare the call context explicitly so only the Settings
241 + # declare the call context explicitly so only the Settings
145 242 # "Sync Now" button consumes/stamps the 5-minute manual cooldown.
146 243 $is_heartbeat_tick = filter_var($_POST['is_heart_beat'] ?? false, FILTER_VALIDATE_BOOLEAN);
147 244
148 245 # get the response
@@ -416,8 +513,48 @@
416 513 }
417 514 }
418 515 }
419 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 +
420 557 public function ajax_create_redirect_from_404()
421 558 {
422 559 if (empty($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_404_redirect')) {
423 560 wp_die('Security check failed');
@@ -426,10 +563,10 @@
426 563 if (!Metasync::current_user_has_plugin_access()) {
427 564 wp_die('Insufficient permissions');
428 565 }
429 566
430 - $uri = sanitize_text_field($_POST['uri']);
431 - $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']));
432 569
433 570 if (empty($uri) || empty($redirect_url)) {
434 571 wp_send_json_error('Missing required parameters');
435 572 }
@@ -447,91 +584,33 @@
447 584 wp_send_json_error('Failed to create redirect');
448 585 }
449 586 }
450 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 + */
451 594 public function ajax_test_host_blocking_get()
452 595 {
453 - check_ajax_referer('metasync_nonce', 'nonce');
454 - if (!Metasync::current_user_has_plugin_access()) {
455 - wp_send_json_error('Insufficient permissions');
456 - return;
457 - }
596 + $this->send_host_blocking_result('GET');
597 + }
458 598
459 - $endpoint = 'https://wp-check.searchatlas.com/ping';
460 - $start_time = microtime(true);
461 -
462 - $response = wp_remote_get($endpoint, array(
463 - 'timeout' => 30,
464 - 'user-agent' => 'MetaSync Plugin Host Test',
465 - 'headers' => array(
466 - 'Accept' => 'application/json',
467 - 'Content-Type' => 'application/json',
468 - 'Origin' => home_url(),
469 - 'Referer' => admin_url(),
470 - 'X-WordPress-Site' => home_url()
471 - )
472 - ));
473 -
474 - $end_time = microtime(true);
475 - $response_time = round(($end_time - $start_time) * 1000, 2);
476 -
477 - if (is_wp_error($response)) {
478 - wp_send_json_success(array(
479 - 'method' => 'GET',
480 - 'status' => 'error',
481 - 'response_time' => $response_time . 'ms',
482 - 'error' => $response->get_error_message(),
483 - 'blocked' => true,
484 - 'details' => 'Request failed - possible blocking detected'
485 - ));
486 - } else {
487 - $status_code = wp_remote_retrieve_response_code($response);
488 - $body = wp_remote_retrieve_body($response);
489 - $headers = wp_remote_retrieve_headers($response);
490 -
491 - $is_blocked = false;
492 - $status_text = 'success';
493 - $details = 'GET request completed successfully';
494 - $parsed_response = null;
495 -
496 - if ($status_code === 200) {
497 - $parsed_response = json_decode($body, true);
498 -
499 - if ($parsed_response && isset($parsed_response['results']['get'])) {
500 - $get_result = $parsed_response['results']['get'];
501 - $get_status_code = isset($get_result['statusCode']) ? $get_result['statusCode'] : null;
502 -
503 - if ($get_status_code !== 200) {
504 - $is_blocked = true;
505 - $status_text = 'error';
506 - $details = "GET request to target site returned status code {$get_status_code} - host blocking detected";
507 - }
508 - } else {
509 - $is_blocked = true;
510 - $status_text = 'error';
511 - $details = 'Unable to parse response structure - possible blocking or endpoint issue';
512 - }
513 - } else {
514 - $is_blocked = true;
515 - $status_text = 'error';
516 - $details = "External endpoint returned status code {$status_code} - possible blocking detected";
517 - }
518 -
519 - wp_send_json_success(array(
520 - 'method' => 'GET',
521 - 'status' => $status_text,
522 - 'response_time' => $response_time . 'ms',
523 - 'status_code' => $status_code,
524 - 'body' => $body,
525 - 'headers' => $headers->getAll(),
526 - 'blocked' => $is_blocked,
527 - 'details' => $details,
528 - 'parsed_response' => $parsed_response
529 - ));
530 - }
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');
531 605 }
532 606
533 - 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)
534 613 {
535 614 check_ajax_referer('metasync_nonce', 'nonce');
536 615 if (!Metasync::current_user_has_plugin_access()) {
537 616 wp_send_json_error('Insufficient permissions');
@@ -537,90 +616,9 @@
537 616 wp_send_json_error('Insufficient permissions');
538 617 return;
539 618 }
540 619
541 - $endpoint = 'https://wp-check.searchatlas.com/ping';
542 - $start_time = microtime(true);
543 -
544 - $test_data = array(
545 - 'test' => 'host_blocking_test',
546 - 'timestamp' => current_time('mysql'),
547 - 'source' => 'metasync_plugin',
548 - 'method' => 'POST'
549 - );
550 -
551 - $response = wp_remote_post($endpoint, array(
552 - 'timeout' => 30,
553 - 'user-agent' => 'MetaSync Plugin Host Test',
554 - 'headers' => array(
555 - 'Accept' => 'application/json',
556 - 'Content-Type' => 'application/json',
557 - 'Origin' => home_url(),
558 - 'Referer' => admin_url(),
559 - 'X-WordPress-Site' => home_url()
560 - ),
561 - 'body' => json_encode($test_data)
562 - ));
563 -
564 - $end_time = microtime(true);
565 - $response_time = round(($end_time - $start_time) * 1000, 2);
566 -
567 - if (is_wp_error($response)) {
568 - wp_send_json_success(array(
569 - 'method' => 'POST',
570 - 'status' => 'error',
571 - 'response_time' => $response_time . 'ms',
572 - 'error' => $response->get_error_message(),
573 - 'blocked' => true,
574 - 'details' => 'Request failed - possible blocking detected',
575 - 'sent_data' => $test_data
576 - ));
577 - } else {
578 - $status_code = wp_remote_retrieve_response_code($response);
579 - $body = wp_remote_retrieve_body($response);
580 - $headers = wp_remote_retrieve_headers($response);
581 -
582 - $is_blocked = false;
583 - $status_text = 'success';
584 - $details = 'POST request completed successfully';
585 - $parsed_response = null;
586 -
587 - if ($status_code === 200) {
588 - $parsed_response = json_decode($body, true);
589 -
590 - if ($parsed_response && isset($parsed_response['results']['post'])) {
591 - $post_result = $parsed_response['results']['post'];
592 - $post_status_code = isset($post_result['statusCode']) ? $post_result['statusCode'] : null;
593 -
594 - if ($post_status_code !== 200) {
595 - $is_blocked = true;
596 - $status_text = 'error';
597 - $details = "POST request to target site returned status code {$post_status_code} - host blocking detected";
598 - }
599 - } else {
600 - $is_blocked = true;
601 - $status_text = 'error';
602 - $details = 'Unable to parse response structure - possible blocking or endpoint issue';
603 - }
604 - } else {
605 - $is_blocked = true;
606 - $status_text = 'error';
607 - $details = "External endpoint returned status code {$status_code} - possible blocking detected";
608 - }
609 -
610 - wp_send_json_success(array(
611 - 'method' => 'POST',
612 - 'status' => $status_text,
613 - 'response_time' => $response_time . 'ms',
614 - 'status_code' => $status_code,
615 - 'body' => $body,
616 - 'headers' => $headers->getAll(),
617 - 'blocked' => $is_blocked,
618 - 'details' => $details,
619 - 'sent_data' => $test_data,
620 - 'parsed_response' => $parsed_response
621 - ));
622 - }
620 + wp_send_json_success(Metasync_Host_Blocking_Check::get_instance()->run_check($method));
623 621 }
624 622
625 623 public function execute_transient_cleanup()
626 624 {
@@ -637,9 +635,9 @@
637 635 try {
638 636 delete_expired_transients(true);
639 637 $cleanup_stats['expired_transients'] = 'cleaned_by_wordpress';
640 638
641 - $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
642 640 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_%'",
643 641 ARRAY_A
644 642 );
645 643
@@ -648,9 +646,9 @@
648 646 delete_transient($transient_name);
649 647 $cleanup_stats['plugin_transients']++;
650 648 }
651 649
652 - $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
653 651 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
654 652 ARRAY_A
655 653 );
656 654
@@ -659,9 +657,9 @@
659 657 delete_transient($transient_name);
660 658 $cleanup_stats['rate_limit_transients']++;
661 659 }
662 660
663 - $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
664 662 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_telemetry_%'",
665 663 ARRAY_A
666 664 );
667 665
@@ -670,9 +668,9 @@
670 668 delete_transient($transient_name);
671 669 $cleanup_stats['telemetry_transients']++;
672 670 }
673 671
674 - $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
675 673 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_sa_connect_success_%'",
676 674 ARRAY_A
677 675 );
678 676
@@ -683,14 +681,12 @@
683 681 }
684 682
685 683 $cleanup_stats['execution_time'] = round((microtime(true) - $cleanup_stats['start_time']) * 1000, 2);
686 684 $cleanup_stats['next_run'] = wp_next_scheduled('metasync_cleanup_transients') ?
687 - date('Y-m-d H:i:s T', wp_next_scheduled('metasync_cleanup_transients')) : 'N/A';
685 + gmdate('Y-m-d H:i:s T', wp_next_scheduled('metasync_cleanup_transients')) : 'N/A';
688 686
689 - error_log('MetaSync: Transient cleanup completed - ' . json_encode($cleanup_stats));
690 -
691 687 } catch (Exception $e) {
692 - 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
693 689 }
694 690 }
695 691
696 692 public function ajax_submit_issue_report()
@@ -820,9 +816,9 @@
820 816 # Check if Sentry feedback function exists
821 817 if (!function_exists('metasync_sentry_capture_feedback')) {
822 818 # Log warning if function doesn't exist
823 819 if (defined('WP_DEBUG') && WP_DEBUG) {
824 - 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
825 821 }
826 822 } else {
827 823 $feedback_data = array(
828 824 'message' => $issue_message,
@@ -849,13 +845,11 @@
849 845 ));
850 846 } else {
851 847 # Fallback: Log locally if Sentry fails or is unavailable
852 848 if (defined('WP_DEBUG') && WP_DEBUG) {
853 - error_log(sprintf(
854 - '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',
855 851 $project_uuid,
856 - $issue_title,
857 - $issue_message,
858 852 $issue_severity
859 853 ));
860 854 }
861 855
@@ -869,9 +863,9 @@
869 863
870 864 } catch (Exception $e) {
871 865 # Log the error securely (only in debug mode)
872 866 if (defined('WP_DEBUG') && WP_DEBUG) {
873 - error_log(sprintf(
867 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
874 868 'MetaSync Report Submission Error: %s in %s on line %d',
875 869 $e->getMessage(),
876 870 $e->getFile(),
877 871 $e->getLine()
@@ -884,8 +878,9 @@
884 878 }
885 879
886 880 public function ajax_recover_password()
887 881 {
882 + $rate_owner = '';
888 883 try {
889 884 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_recover_password_nonce')) {
890 885 wp_send_json_error(array('message' => 'Security verification failed.'));
891 886 return;
@@ -890,26 +885,87 @@
890 885 wp_send_json_error(array('message' => 'Security verification failed.'));
891 886 return;
892 887 }
893 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 +
894 905 $whitelabel_settings = Metasync::get_whitelabel_settings();
895 - $password = $whitelabel_settings['settings_password'] ?? '';
896 906 $recovery_email = $whitelabel_settings['recovery_email'] ?? '';
897 907
898 - 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 = '';
899 912 wp_send_json_error(array('message' => 'No password is configured for recovery.'));
900 913 return;
901 914 }
902 915
903 916 if (empty($recovery_email) || !is_email($recovery_email)) {
917 + $this->release_recovery_lock($rate_owner);
918 + $rate_owner = '';
904 919 wp_send_json_error(array('message' => 'No valid recovery email is configured. Please contact your administrator.'));
905 920 return;
906 921 }
907 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 +
908 964 $site_name = get_bloginfo('name');
909 965 $site_url = home_url();
910 966 $plugin_name = Metasync::get_effective_plugin_name('');
911 - $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));
912 968 $to = $recovery_email;
913 969
914 970 $subject = sprintf('[%s] Settings Password Recovery', $site_name);
915 971
@@ -915,16 +971,14 @@
915 971
916 972 $message = sprintf(
917 973 "Hello,\n\n" .
918 974 "A password recovery request was made for the %s settings on %s.\n\n" .
919 - "Your Settings Password is:\n%s\n\n" .
920 - "You can use this password to access the protected settings at:\n%s\n\n" .
975 + "Use this one-time link to choose a new Settings Password (valid for 30 minutes):\n%s\n\n" .
921 976 "If you did not request this password recovery, please secure your WordPress admin account immediately.\n\n" .
922 977 "---\n" .
923 978 "This is an automated message from %s\n%s",
924 979 $plugin_name,
925 980 $site_name,
926 - $password,
927 981 $settings_url,
928 982 $site_name,
929 983 $site_url
930 984 );
@@ -937,13 +991,8 @@
937 991 'Content-Type: text/plain; charset=UTF-8',
938 992 sprintf('From: %s <%s>', $from_name, get_option('admin_email'))
939 993 );
940 994
941 - $mail_error = '';
942 - add_action('wp_mail_failed', function($error) use (&$mail_error) {
943 - $mail_error = $error->get_error_message();
944 - });
945 -
946 995 $sent = wp_mail($to, $subject, $message, $headers);
947 996
948 997 if ($sent) {
949 998 wp_send_json_success(array(
@@ -949,21 +998,19 @@
949 998 wp_send_json_success(array(
950 999 'message' => sprintf('Password recovery email sent to %s', esc_html($recovery_email))
951 1000 ));
952 1001 } else {
953 - $error_message = 'Failed to send recovery email. ';
954 - if (!empty($mail_error)) {
955 - $error_message .= 'Error: ' . $mail_error;
956 - } else {
957 - $error_message .= 'Your server may not be configured to send emails. Please check your email configuration or contact your administrator.';
958 - }
959 -
960 - wp_send_json_error(array('message' => $error_message));
1002 + wp_send_json_error(array('message' => 'Failed to send recovery email. Please check your email configuration or contact your administrator.'));
961 1003 }
962 1004
963 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 + }
964 1011 if (defined('WP_DEBUG') && WP_DEBUG) {
965 - error_log(sprintf(
1012 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
966 1013 'MetaSync Password Recovery Error: %s in %s on line %d',
967 1014 $e->getMessage(),
968 1015 $e->getFile(),
969 1016 $e->getLine()
@@ -973,8 +1020,113 @@
973 1020 wp_send_json_error(array('message' => 'An error occurred while processing your request. Please try again later.'));
974 1021 }
975 1022 }
976 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 +
977 1129 public function ajax_save_theme()
978 1130 {
979 1131 try {
980 1132 # Verify nonce for security
@@ -979,15 +1131,13 @@
979 1131 try {
980 1132 # Verify nonce for security
981 1133 if (!isset($_POST['_ajax_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_ajax_nonce'])), 'metasync_theme_nonce')) {
982 1134 wp_send_json_error(array('message' => 'Security verification failed.'));
983 - return;
984 1135 }
985 1136
986 1137 # Check user capabilities
987 1138 if (!Metasync::current_user_has_plugin_access()) {
988 1139 wp_send_json_error(array('message' => 'Insufficient permissions.'));
989 - return;
990 1140 }
991 1141
992 1142 # Get and validate theme value
993 1143 $theme = isset($_POST['theme']) ? sanitize_text_field(wp_unslash($_POST['theme'])) : '';
@@ -994,9 +1144,8 @@
994 1144
995 1145 # Validate theme is either 'light' or 'dark'
996 1146 if (!in_array($theme, array('light', 'dark'), true)) {
997 1147 wp_send_json_error(array('message' => 'Invalid theme value.'));
998 - return;
999 1148 }
1000 1149
1001 1150 # Save theme preference to WordPress options
1002 1151 update_option('metasync_theme', $theme, true);
@@ -1009,9 +1158,9 @@
1009 1158
1010 1159 } catch (Exception $e) {
1011 1160 # Log error if debug is enabled
1012 1161 if (defined('WP_DEBUG') && WP_DEBUG) {
1013 - 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
1014 1163 }
1015 1164
1016 1165 wp_send_json_error(array('message' => 'Failed to save theme preference.'));
1017 1166 }
@@ -1022,9 +1171,8 @@
1022 1171 check_ajax_referer('metasync_nonce', 'nonce');
1023 1172 # Check user capabilities
1024 1173 if (!Metasync::current_user_has_plugin_access()) {
1025 1174 wp_send_json_error(['message' => 'Insufficient permissions']);
1026 - return;
1027 1175 }
1028 1176
1029 1177 # Get parameters
1030 1178 $auth_method = isset($_POST['auth_method']) ? sanitize_text_field(wp_unslash($_POST['auth_method'])) : 'searchatlas_connect';
@@ -1068,9 +1216,17 @@
1068 1216
1069 1217 try {
1070 1218 # Get all whitelabel settings
1071 1219 $whitelabel_settings = Metasync::get_whitelabel_settings();
1072 -
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 +
1073 1229 # Get general settings that relate to whitelabel
1074 1230 $general_settings = Metasync::get_option('general');
1075 1231 $whitelabel_related_general = array();
1076 1232
@@ -1145,9 +1301,9 @@
1145 1301 wp_mkdir_p($temp_dir);
1146 1302 }
1147 1303
1148 1304 # Generate unique filename
1149 - $timestamp = date('Y-m-d_H-i-s');
1305 + $timestamp = gmdate('Y-m-d_H-i-s');
1150 1306 $zip_filename = 'metasync-whitelabel-plugin-' . $timestamp . '.zip';
1151 1307 $json_filename = 'whitelabel-settings.json';
1152 1308 $zip_path = $temp_dir . '/' . $zip_filename;
1153 1309
@@ -1208,14 +1364,14 @@
1208 1364 header('Pragma: no-cache');
1209 1365 header('Expires: 0');
1210 1366
1211 1367 # Output file and clean up
1212 - readfile($zip_path);
1213 - 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);
1214 1370
1215 1371 # Clean up temp directory if empty
1216 1372 if (is_dir($temp_dir) && count(scandir($temp_dir)) == 2) {
1217 - 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
1218 1374 }
1219 1375
1220 1376 # Exit to prevent WordPress from adding anything to the response
1221 1377 exit;
@@ -1222,12 +1378,12 @@
1222 1378
1223 1379 } catch (Exception $e) {
1224 1380 # Log error if debug is enabled
1225 1381 if (defined('WP_DEBUG') && WP_DEBUG) {
1226 - 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
1227 1383 }
1228 1384
1229 - wp_die('Failed to export whitelabel settings: ' . $e->getMessage());
1385 + wp_die(esc_html__('Failed to export whitelabel settings.', 'metasync'));
1230 1386 }
1231 1387 }
1232 1388
1233 1389 private static function add_directory_to_zip($zip, $dir, $zip_path = '', $exclude_patterns = array())
@@ -1272,10 +1428,10 @@
1272 1428 public function render_html_pages_dashboard_widget()
1273 1429 {
1274 1430 global $wpdb;
1275 1431
1276 - $query = "
1277 - 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
1278 1434 FROM {$wpdb->posts} p
1279 1435 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
1280 1436 WHERE p.post_status = 'publish'
1281 1437 AND p.post_type IN ('post', 'page')
@@ -1282,11 +1438,10 @@
1282 1438 AND (pm.meta_key = '_metasync_raw_html_enabled' OR pm.meta_key = '_metasync_custom_css')
1283 1439 GROUP BY p.ID
1284 1440 ORDER BY p.post_modified DESC
1285 1441 LIMIT 10
1286 - ";
1287 -
1288 - $html_pages = $wpdb->get_results($query);
1442 + "
1443 + );
1289 1444 $total_count = count($html_pages);
1290 1445
1291 1446 $label = $this->get_html_source_label();
1292 1447
@@ -1293,13 +1448,14 @@
1293 1448 if (empty($html_pages)) {
1294 1449 echo '<div class="metasync-dashboard-widget-empty">';
1295 1450 echo '<span class="dashicons dashicons-admin-page" style="font-size: 48px; opacity: 0.3; display: block; margin: 20px auto;"></span>';
1296 1451 echo '<p style="text-align: center; color: #666;">';
1452 + /* translators: %s: HTML source label. */
1297 1453 echo sprintf(__('No pages created with %s yet.', 'metasync'), '<strong>' . esc_html($label) . '</strong>');
1298 1454 echo '</p>';
1299 1455 echo '<p style="text-align: center;">';
1300 - echo '<a href="' . admin_url('admin.php?page=' . Metasync_Admin::$page_slug) . '" class="button button-primary">';
1301 - echo __('Get Started', 'metasync');
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');
1302 1458 echo '</a>';
1303 1459 echo '</p>';
1304 1460 echo '</div>';
1305 1461 return;
@@ -1308,15 +1464,15 @@
1308 1464 echo '<div class="metasync-dashboard-widget">';
1309 1465
1310 1466 echo '<div class="metasync-widget-stats">';
1311 1467 echo '<div class="metasync-stat-box">';
1312 - echo '<span class="metasync-stat-number">' . $total_count . '</span>';
1313 - echo '<span class="metasync-stat-label">' . __('AI-Generated Pages', 'metasync') . '</span>';
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>';
1314 1470 echo '</div>';
1315 1471 echo '</div>';
1316 1472
1317 1473 echo '<div class="metasync-widget-list">';
1318 - echo '<h4>' . __('Recent Pages', 'metasync') . '</h4>';
1474 + echo '<h4>' . esc_html__('Recent Pages', 'metasync') . '</h4>';
1319 1475 echo '<ul>';
1320 1476
1321 1477 foreach ($html_pages as $page) {
1322 1478 $edit_link = get_edit_post_link($page->ID);
@@ -1329,11 +1485,12 @@
1329 1485 echo '<a href="' . esc_url($edit_link) . '" class="metasync-page-title">';
1330 1486 echo esc_html($page->post_title ?: __('(no title)', 'metasync'));
1331 1487 echo '</a>';
1332 1488 echo '<span class="metasync-page-meta">';
1333 - 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));
1334 1491 echo ' • ';
1335 - 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>';
1336 1493 echo '</span>';
1337 1494 echo '</div>';
1338 1495 echo '</li>';
1339 1496 }
@@ -1341,10 +1498,10 @@
1341 1498 echo '</ul>';
1342 1499 echo '</div>';
1343 1500
1344 1501 echo '<div class="metasync-widget-footer">';
1345 - echo '<a href="' . admin_url('edit.php?post_type=page') . '">';
1346 - 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') . ' →';
1347 1504 echo '</a>';
1348 1505 echo '</div>';
1349 1506
1350 1507 echo '</div>';