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