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