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 | admin/class-metasync-admin.php +297 -237 2.6.26 → 2.7.1 View file →
@@ -212,9 +212,8 @@
212 212 add_action('admin_menu', array($this, 'add_import_external_data_page'));
213 213 add_action('admin_init', array($this, 'settings_page_init'));
214 214 add_filter('all_plugins', array($this,'metasync_plugin_white_label'));
215 215 add_filter( 'plugin_row_meta',array($this,'metasync_view_detials_url'),10,3);
216 - add_filter('site_transient_update_plugins', array($this, 'inject_whitelabel_icon_into_update_transient'));
217 216
218 217 // Display transient error/success messages for redirections
219 218 add_action('admin_notices', array($this, 'display_redirection_messages'));
220 219
@@ -263,8 +262,9 @@
263 262 add_action('metasync_api_key_changed', array('Metasync_Admin_Navigation', 'invalidate_admin_bar_status_cache'), 10, 0);
264 263
265 264 add_action('admin_init', array($this, 'initialize_cookie'));
266 265 add_action('admin_init', array($this, 'maybe_redirect_to_wizard'));
266 + add_action('admin_init', array($this, 'redirect_legacy_instant_indexing_page'));
267 267
268 268 // Add admin_post hooks for form submissions (WordPress standard way - no output buffering needed)
269 269 add_action('admin_post_metasync_clear_all_cache_plugins', array($this, 'handle_clear_all_cache_plugins'));
270 270 add_action('admin_post_metasync_clear_otto_cache_all', array($this, 'handle_clear_otto_cache_all'));
@@ -282,8 +282,11 @@
282 282
283 283 // Add AJAX for saving execution settings
284 284 add_action( 'wp_ajax_metasync_save_execution_settings', array($this, 'ajax_save_execution_settings') );
285 285
286 + // Add AJAX for saving the Headless Mode demo controls on the Advanced tab
287 + add_action( 'wp_ajax_metasync_save_headless_settings', array(Metasync_Settings_Registration::instance(), 'ajax_save_headless_settings') );
288 +
286 289 // Add AJAX for saving hosting cache settings
287 290 add_action( 'wp_ajax_metasync_save_hosting_cache_settings', array($this, 'ajax_save_hosting_cache_settings') );
288 291
289 292 // Add AJAX for saving OTTO Cache TTL
@@ -372,8 +375,15 @@
372 375 add_action('wp_ajax_metasync_process_batch_tick', array($this, 'ajax_process_batch_tick'));
373 376 add_action('wp_ajax_metasync_delete_orphaned_image', array($this, 'ajax_delete_orphaned_image'));
374 377 add_action('metasync_media_batch_optimize_cron', array($this, 'handle_media_batch_cron'));
375 378
379 + # SEO Restore (Rollback) AJAX & Cron handlers
380 + add_action('wp_ajax_metasync_seo_restore_start', array($this, 'ajax_seo_restore_start'));
381 + add_action('wp_ajax_metasync_seo_restore_cancel', array($this, 'ajax_seo_restore_cancel'));
382 + add_action('wp_ajax_metasync_seo_restore_progress', array($this, 'ajax_seo_restore_progress'));
383 + add_action('wp_ajax_metasync_seo_restore_process_tick', array($this, 'ajax_seo_restore_process_tick'));
384 + add_action('metasync_seo_restore_cron', array($this, 'handle_seo_restore_cron'));
385 +
376 386 # Add AJAX handlers for Google Instant Indexing
377 387 add_action('wp_ajax_metasync_send_giapi', array($this, 'ajax_send_giapi'));
378 388
379 389 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
@@ -487,9 +497,9 @@
487 497 <?php
488 498 }
489 499
490 500 public function metasync_fouc_prevention_style() {
491 - if ( ! isset( $_GET['page'] ) || strpos( $_GET['page'], self::$page_slug ) !== 0 ) {
501 + if ( ! isset( $_GET['page'] ) || strpos( sanitize_key(wp_unslash($_GET['page'])), self::$page_slug ) !== 0 ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check for an inline style
492 502 return;
493 503 }
494 504 ?>
495 505 <style>
@@ -511,13 +521,13 @@
511 521 <?php
512 522 }
513 523
514 524 public function suppress_notices_on_wizard_page() {
515 - if ( ! isset( $_GET['page'] ) ) {
525 + if ( ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
516 526 return;
517 527 }
518 528
519 - $page = sanitize_text_field( wp_unslash( $_GET['page'] ) );
529 + $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
520 530
521 531 // Setup wizard: strip every notice for a fully focused, distraction-free screen.
522 532 if ( strpos( $page, '-setup-wizard' ) !== false ) {
523 533 remove_all_actions( 'admin_notices' );
@@ -635,9 +645,9 @@
635 645
636 646 #Redirect url to the new slug
637 647 $redirect_url = admin_url('admin.php?page=' . $current_slug);
638 648
639 - wp_redirect($redirect_url);
649 + wp_safe_redirect($redirect_url);
640 650 exit;
641 651 }
642 652 }
643 653
@@ -689,13 +699,13 @@
689 699 * @since 1.0.0
690 700 */
691 701 public function maybe_redirect_to_wizard() {
692 702 // Only redirect if wizard should be shown
693 - if (get_option('metasync_show_wizard') && !isset($_GET['page'])) {
703 + if (get_option('metasync_show_wizard') && !isset($_GET['page'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- isset() routing check for a redirect to a fixed admin URL
694 704 delete_option('metasync_show_wizard');
695 705
696 706 // Don't redirect during AJAX, cron, or bulk activation
697 - if (wp_doing_ajax() || wp_doing_cron() || isset($_GET['activate-multi'])) {
707 + if (wp_doing_ajax() || wp_doing_cron() || isset($_GET['activate-multi'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- isset() routing check for a redirect to a fixed admin URL
698 708 return;
699 709 }
700 710
701 711 // Only redirect if user has access to the plugin
@@ -707,11 +717,56 @@
707 717 exit;
708 718 }
709 719 }
710 720
721 + /**
722 + * Redirect the removed standalone Instant Indexing page to Indexation Control.
723 + *
724 + * The menu entry is gone, but bookmarks and external links can still target
725 + * the old URL. Resolve the current white-label slug rather than assuming
726 + * "searchatlas".
727 + */
728 + public function redirect_legacy_instant_indexing_page() {
729 + if (wp_doing_ajax() || wp_doing_cron() || !is_admin() || !isset($_GET['page'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
730 + return;
731 + }
732 +
733 + $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
734 + $legacy_page = self::$page_slug . '-instant-index';
735 + if ($page !== $legacy_page || !Metasync::current_user_has_plugin_access()) {
736 + return;
737 + }
738 +
739 + // Indexation Control is registered only when access control keeps it
740 + // visible. Redirecting to it blindly would dead-end on an
741 + // unregistered slug (wp_die "Invalid plugin page") for users whose
742 + // white-label config hides that page — the exact configs where the
743 + // removed standalone page was the feature's only UI. The bare plugin
744 + // slug is the floor: its top-level callback resolves to the first
745 + // reachable page, so it is always a safe target.
746 + if (Metasync_Access_Control::user_can_access('hide_indexation_control')) {
747 + $target = admin_url('admin.php?page=' . self::$page_slug . '-seo-controls');
748 + $query = [];
749 + foreach (['tab', 'subtab'] as $key) {
750 + if (isset($_GET[$key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
751 + $query[$key] = sanitize_key(wp_unslash($_GET[$key])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
752 + }
753 + }
754 + if (!empty($query)) {
755 + $target = add_query_arg($query, $target);
756 + }
757 + } else {
758 + $target = admin_url('admin.php?page=' . self::$page_slug);
759 + }
760 +
761 + wp_safe_redirect($target);
762 + exit;
763 + }
764 +
711 765 public function metasync_display_error_log() {
712 766 Metasync_Debug_Manager::instance()->metasync_display_error_log($this);
713 767 }
768 +
714 769 public function metasync_update_wp_config() {
715 770 Metasync_Debug_Manager::instance()->metasync_update_wp_config();
716 771 }
717 772
@@ -754,9 +809,9 @@
754 809 ]);
755 810 $_COOKIE['metasync_previous_slug'] = $new_slug;
756 811 // Redirect to the new slug
757 812 $redirect_url = admin_url('admin.php?page=' . $old_slug);
758 - wp_redirect($redirect_url);
813 + wp_safe_redirect($redirect_url);
759 814 exit;
760 815 }else{
761 816 self::$page_slug = Metasync::get_option('general')['white_label_plugin_menu_slug']=="" ? "searchatlas":Metasync::get_option('general')['white_label_plugin_menu_slug'];
762 817 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
@@ -762,9 +817,9 @@
762 817 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
763 818
764 819 #add redirection for when the old slug is not defined
765 820 #this fixes the redirect issue #
766 - wp_redirect($redirect_url);
821 + wp_safe_redirect($redirect_url);
767 822 exit;
768 823 }
769 824 }
770 825 public function metasync_view_detials_url( $plugin_meta, $plugin_file, $plugin_data ) {
@@ -775,9 +830,10 @@
775 830 if (strpos($meta, 'open-plugin-details-modal') !== false) {
776 831 $meta = sprintf(
777 832 '<a href="%s" class="thickbox open-plugin-details-modal" aria-label="%s" data-title="%s">%s</a>',
778 833 add_query_arg('TB_iframe', 'true', $plugin_uri),
779 - esc_attr(sprintf(esc_html__('More information about %s'), $plugin_data['Name'])),
834 + /* translators: %s: plugin name. */
835 + esc_attr(sprintf(esc_html__('More information about %s', 'metasync'), $plugin_data['Name'])),
780 836 esc_attr($plugin_data['Name']),
781 837 esc_html__('View details', 'metasync')
782 838 );
783 839 break; // Exit loop after replacing the link
@@ -825,53 +881,8 @@
825 881 return $all_plugins;
826 882 }
827 883
828 884 /**
829 - * Inject the whitelabel icon into the update_plugins transient so that
830 - * /wp-admin/update-core.php (Dashboard → Updates) shows the WL icon
831 - * instead of the SearchAtlas icon returned by the update API.
832 - *
833 - * @param object $transient The site transient object.
834 - * @return object
835 - */
836 - public function inject_whitelabel_icon_into_update_transient($transient) {
837 - if (empty($transient) || !is_object($transient)) {
838 - return $transient;
839 - }
840 -
841 - $general = Metasync::get_option('general');
842 - if (!is_array($general)) {
843 - return $transient;
844 - }
845 -
846 - $icon_url = $general['white_label_plugin_menu_icon'] ?? '';
847 - if (empty($icon_url)) {
848 - return $transient;
849 - }
850 -
851 - $this_plugin = plugin_basename(dirname(__DIR__) . '/metasync.php');
852 -
853 - // Inject into pending updates list
854 - if (!empty($transient->response) && isset($transient->response[$this_plugin])) {
855 - $transient->response[$this_plugin]->icons = [
856 - '1x' => $icon_url,
857 - '2x' => $icon_url,
858 - ];
859 - }
860 -
861 - // Also inject into the "no update needed" list so the icon appears
862 - // on the updates screen even when the plugin is up-to-date
863 - if (!empty($transient->no_update) && isset($transient->no_update[$this_plugin])) {
864 - $transient->no_update[$this_plugin]->icons = [
865 - '1x' => $icon_url,
866 - '2x' => $icon_url,
867 - ];
868 - }
869 -
870 - return $transient;
871 - }
872 -
873 - /**
874 885 * Register the stylesheets for the admin area.
875 886 *
876 887 * @since 1.0.0
877 888 */
@@ -876,9 +887,9 @@
876 887 * @since 1.0.0
877 888 */
878 889 public function enqueue_styles()
879 890 {
880 - $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
891 + $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
881 892
882 893 if (strpos($current_page, self::$page_slug) === 0) {
883 894 wp_enqueue_style(
884 895 $this->plugin_name,
@@ -907,9 +918,9 @@
907 918 );
908 919 }
909 920
910 921 // Enqueue wizard CSS if on wizard page
911 - if (isset($_GET['page']) && strpos($_GET['page'], '-setup-wizard') !== false) {
922 + if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-setup-wizard') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
912 923 wp_enqueue_style(
913 924 $this->plugin_name . '-setup-wizard',
914 925 plugin_dir_url(__FILE__) . 'css/metasync-setup-wizard.css',
915 926 array($this->plugin_name . '-dashboard'),
@@ -918,9 +929,9 @@
918 929 );
919 930 }
920 931
921 932 // Enqueue SEO Health CSS if on the SEO Health page
922 - if (isset($_GET['page']) && strpos($_GET['page'], '-seo-health') !== false) {
933 + if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-seo-health') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
923 934 wp_enqueue_style(
924 935 $this->plugin_name . '-seo-health',
925 936 plugin_dir_url(__FILE__) . 'css/metasync-seo-health.css',
926 937 array($this->plugin_name . '-dashboard'),
@@ -937,9 +948,9 @@
937 948 */
938 949 public function enqueue_scripts()
939 950 {
940 951 // --- Phase 5 (#887): Extracted inline JS files ---
941 - $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
952 + $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick scripts
942 953 $plugin_root_url = plugin_dir_url(dirname(__FILE__));
943 954 $is_metasync_page = ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0);
944 955
945 956 if ($is_metasync_page) {
@@ -1000,8 +1011,16 @@
1000 1011 array(),
1001 1012 $this->version,
1002 1013 true
1003 1014 );
1015 + // Selector navigation is a GET, so the per-page nonce rides along
1016 + // as a query parameter and gates the user_meta persist in
1017 + // Metasync_Per_Page_Helper::resolve().
1018 + wp_localize_script(
1019 + $this->plugin_name . '-per-page',
1020 + 'metasyncPerPage',
1021 + array('nonces' => Metasync_Per_Page_Helper::page_nonces())
1022 + );
1004 1023 }
1005 1024
1006 1025 // Dashboard iframe height (only on dashboard page)
1007 1026 if ($current_page === self::$page_slug . '-dashboard' || $current_page === self::$page_slug) {
@@ -1052,12 +1071,12 @@
1052 1071 true
1053 1072 );
1054 1073 // Pass the active tab from server (handles POST redirect)
1055 1074 $sitemap_active_tab = 'general';
1056 - if (isset($_GET['tab']) && in_array($_GET['tab'], ['general', 'news', 'video'], true)) {
1057 - $sitemap_active_tab = sanitize_text_field(wp_unslash($_GET['tab']));
1058 - } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) {
1059 - $sitemap_active_tab = sanitize_text_field(wp_unslash($_POST['redirect_tab']));
1075 + if (isset($_GET['tab']) && in_array($_GET['tab'], ['general', 'news', 'video'], true)) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed tab selection for the UI
1076 + $sitemap_active_tab = sanitize_text_field(wp_unslash($_GET['tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed tab selection for the UI
1077 + } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only, allow-listed tab selection for the UI
1078 + $sitemap_active_tab = sanitize_text_field(wp_unslash($_POST['redirect_tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only, allow-listed tab selection for the UI
1060 1079 }
1061 1080 wp_localize_script($this->plugin_name . '-sitemap-tabs', 'metasyncSitemapTabs', [
1062 1081 'activeTab' => $sitemap_active_tab,
1063 1082 ]);
@@ -1385,8 +1404,10 @@
1385 1404 'nonce' => wp_create_nonce('metasync_nonce'),
1386 1405 'sa_connect_nonce' => $sa_connect_nonce,
1387 1406 'reset_auth_nonce' => wp_create_nonce('metasync_reset_auth_nonce'),
1388 1407 'burst_ping_nonce' => wp_create_nonce('metasync_burst_ping'),
1408 + 'clear_otto_cache_nonce' => wp_create_nonce('metasync_clear_otto_cache'),
1409 + 'lglogin_nonce' => wp_create_nonce('metasync_lglogin'),
1389 1410 'heartbeat_state' => $heartbeat_state,
1390 1411 'dashboard_domain' => self::get_effective_dashboard_domain(),
1391 1412 'support_email' => Metasync::SUPPORT_EMAIL,
1392 1413 'documentation_domain' => Metasync::DOCUMENTATION_DOMAIN,
@@ -1450,9 +1471,9 @@
1450 1471 add_action('admin_notices', array($this, 'display_page_builder_notice'));
1451 1472 // Display update warning banner if plugin update is available
1452 1473 add_action('admin_notices', array($this, 'display_update_warning_banner'));
1453 1474 // Enqueue wizard assets if on wizard page
1454 - if (isset($_GET['page']) && strpos($_GET['page'], '-setup-wizard') !== false) {
1475 + if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-setup-wizard') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick scripts
1455 1476 wp_enqueue_script(
1456 1477 $this->plugin_name . '-setup-wizard',
1457 1478 plugin_dir_url(__FILE__) . 'js/metasync-setup-wizard.js',
1458 1479 array('jquery'),
@@ -1651,13 +1672,8 @@
1651 1672 {
1652 1673 Metasync_Connect_Manager::instance()->test_searchatlas_ajax_endpoint();
1653 1674 }
1654 1675
1655 - public function simple_ajax_test()
1656 - {
1657 - Metasync_Connect_Manager::instance()->simple_ajax_test();
1658 - }
1659 -
1660 1676 /**
1661 1677 * Create Admin Dashboard Iframe Page
1662 1678 * Embeds the Search Atlas dashboard directly in WordPress admin
1663 1679 */
@@ -1744,9 +1760,9 @@
1744 1760 } else {
1745 1761 echo '<p style="color: var(--dashboard-text-secondary);">ℹ️ No cache plugins detected.</p>';
1746 1762 }
1747 1763 } catch (Exception $e) {
1748 - error_log('MetaSync Cache Status Error: ' . $e->getMessage());
1764 + error_log('MetaSync Cache Status Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1749 1765 echo '<p style="color: var(--dashboard-error);">⚠️ An error occurred while retrieving cache plugin status.</p>';
1750 1766 }
1751 1767 } else {
1752 1768 echo '<p style="color: var(--dashboard-error);">⚠️ Cache Purge class not loaded.</p>';
@@ -1763,12 +1779,12 @@
1763 1779 </form>
1764 1780
1765 1781 <?php
1766 1782 // Display success/error messages
1767 - if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') {
1768 - $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0;
1769 - $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0;
1770 - $plugins = isset($_GET['plugins']) ? sanitize_text_field(wp_unslash($_GET['plugins'])) : '';
1783 + if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1784 + $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1785 + $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1786 + $plugins = isset($_GET['plugins']) ? sanitize_text_field(wp_unslash($_GET['plugins'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1771 1787
1772 1788 if ($cleared > 0) {
1773 1789 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1774 1790 echo '✅ <strong>Success!</strong> Cleared cache for ' . intval( $cleared ) . ' plugin(s)';
@@ -1788,10 +1804,10 @@
1788 1804 echo '</p></div>';
1789 1805 }
1790 1806 }
1791 1807
1792 - if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') {
1793 - $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : '';
1808 + if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1809 + $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1794 1810 if (empty($message)) {
1795 1811 $message = 'An unknown error occurred while clearing cache. Please check error logs for details.';
1796 1812 }
1797 1813 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
@@ -1888,12 +1904,13 @@
1888 1904 </form>
1889 1905
1890 1906 <?php
1891 1907 // Hosting cache result messages
1892 - if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') {
1893 - $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode($_GET['hc_cleared'])) : '';
1894 - $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode($_GET['hc_failed'])) : '';
1895 - $hc_not_detected = isset($_GET['hc_not_detected']) ? sanitize_text_field(urldecode($_GET['hc_not_detected'])) : '';
1908 + if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1909 + // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each value is unslashed and sanitized on the following assignments.
1910 + $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_cleared']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1911 + $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_failed']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1912 + $hc_not_detected = isset($_GET['hc_not_detected']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_not_detected']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1896 1913
1897 1914 if ($hc_cleared) {
1898 1915 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1899 1916 echo '✅ <strong>Success!</strong> Purged hosting cache on: ' . esc_html($hc_cleared);
@@ -1908,8 +1925,9 @@
1908 1925 echo '<div class="notice notice-info inline" style="margin-top: 10px;"><p>';
1909 1926 echo 'ℹ️ No enabled hosting providers were detected on this server (' . esc_html($hc_not_detected) . ').';
1910 1927 echo '</p></div>';
1911 1928 }
1929 + // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1912 1930 }
1913 1931 ?>
1914 1932
1915 1933 <script>
@@ -2074,11 +2092,11 @@
2074 2092 </div>
2075 2093
2076 2094 <?php
2077 2095 // Display success/error messages
2078 - if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') {
2079 - $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0;
2080 - $url = isset($_GET['url']) ? urldecode(sanitize_text_field(wp_unslash($_GET['url']))) : '';
2096 + if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2097 + $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2098 + $url = isset($_GET['url']) ? urldecode(sanitize_text_field(wp_unslash($_GET['url']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2081 2099
2082 2100 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
2083 2101 if (!empty($url)) {
2084 2102 echo '✅ <strong>Success!</strong> Cleared cache for URL: <code>' . esc_html($url) . '</code> (' . intval( $cleared_count ) . ' entries)';
@@ -2087,10 +2105,10 @@
2087 2105 }
2088 2106 echo '</p></div>';
2089 2107 }
2090 2108
2091 - if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') {
2092 - $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : 'An unknown error occurred.';
2109 + if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2110 + $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : 'An unknown error occurred.'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2093 2111 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
2094 2112 echo '❌ <strong>Error:</strong> ' . esc_html($message);
2095 2113 echo '</p></div>';
2096 2114 }
@@ -2129,9 +2147,9 @@
2129 2147 <td>
2130 2148 <input type="url"
2131 2149 id="otto_cache_url"
2132 2150 name="otto_cache_url"
2133 - value="<?php echo isset($_GET['url']) ? esc_attr(urldecode(sanitize_text_field(wp_unslash($_GET['url'])))) : ''; ?>"
2151 + value="<?php echo isset($_GET['url']) ? esc_attr(urldecode(sanitize_text_field(wp_unslash($_GET['url'])))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce ?>"
2134 2152 class="regular-text"
2135 2153 placeholder="https://example.com/page/"
2136 2154 required />
2137 2155 <p class="description" style="color: var(--dashboard-text-secondary);">Enter the full URL of the page whose cache you want to clear.</p>
@@ -2202,9 +2220,10 @@
2202 2220 /**
2203 2221 * AJAX handler for saving hosting cache settings
2204 2222 */
2205 2223 public function ajax_save_hosting_cache_settings() {
2206 - if (!isset($_POST['hosting_cache_nonce']) || !wp_verify_nonce($_POST['hosting_cache_nonce'], 'metasync_hosting_cache_nonce')) {
2224 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2225 + if (!isset($_POST['hosting_cache_nonce']) || !wp_verify_nonce(wp_unslash($_POST['hosting_cache_nonce']), 'metasync_hosting_cache_nonce')) {
2207 2226 wp_send_json_error(array('message' => 'Security check failed'));
2208 2227 return;
2209 2228 }
2210 2229
@@ -2225,9 +2244,10 @@
2225 2244 /**
2226 2245 * AJAX handler: save object cache behaviour settings
2227 2246 */
2228 2247 public function ajax_save_object_cache_settings() {
2229 - if (!isset($_POST['object_cache_nonce']) || !wp_verify_nonce($_POST['object_cache_nonce'], 'metasync_object_cache_nonce')) {
2248 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2249 + if (!isset($_POST['object_cache_nonce']) || !wp_verify_nonce(wp_unslash($_POST['object_cache_nonce']), 'metasync_object_cache_nonce')) {
2230 2250 wp_send_json_error(array('message' => 'Security check failed'));
2231 2251 return;
2232 2252 }
2233 2253
@@ -2254,9 +2274,10 @@
2254 2274 /**
2255 2275 * AJAX handler for saving OTTO Cache TTL
2256 2276 */
2257 2277 public function ajax_save_otto_cache_ttl() {
2258 - if (!isset($_POST['otto_cache_ttl_nonce']) || !wp_verify_nonce($_POST['otto_cache_ttl_nonce'], 'metasync_otto_cache_ttl_nonce')) {
2278 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2279 + if (!isset($_POST['otto_cache_ttl_nonce']) || !wp_verify_nonce(wp_unslash($_POST['otto_cache_ttl_nonce']), 'metasync_otto_cache_ttl_nonce')) {
2259 2280 wp_send_json_error(array('message' => 'Security check failed.'));
2260 2281 return;
2261 2282 }
2262 2283
@@ -2282,9 +2303,10 @@
2282 2303 /**
2283 2304 * admin_post handler: purge WP Engine and Kinsta hosting-level caches
2284 2305 */
2285 2306 public function handle_purge_hosting_cache() {
2286 - if (!isset($_POST['hosting_cache_purge_nonce']) || !wp_verify_nonce($_POST['hosting_cache_purge_nonce'], 'metasync_hosting_cache_purge_nonce')) {
2307 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2308 + if (!isset($_POST['hosting_cache_purge_nonce']) || !wp_verify_nonce(wp_unslash($_POST['hosting_cache_purge_nonce']), 'metasync_hosting_cache_purge_nonce')) {
2287 2309 wp_die('Security check failed');
2288 2310 }
2289 2311
2290 2312 if (!Metasync::current_user_has_plugin_access()) {
@@ -2304,9 +2326,9 @@
2304 2326 WpeCommon::purge_varnish_cache();
2305 2327 WpeCommon::purge_memcached();
2306 2328 $cleared[] = 'WP Engine';
2307 2329 } catch (Exception $e) {
2308 - error_log('MetaSync: WP Engine hosting cache purge failed - ' . $e->getMessage());
2330 + error_log('MetaSync: WP Engine hosting cache purge failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2309 2331 $failed[] = 'WP Engine';
2310 2332 }
2311 2333 } else {
2312 2334 $not_detected[] = 'WP Engine';
@@ -2319,9 +2341,9 @@
2319 2341 try {
2320 2342 KinstaCache::get_instance()->kinsta_cache_purge_full();
2321 2343 $cleared[] = 'Kinsta';
2322 2344 } catch (Exception $e) {
2323 - error_log('MetaSync: Kinsta hosting cache purge failed - ' . $e->getMessage());
2345 + error_log('MetaSync: Kinsta hosting cache purge failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2324 2346 $failed[] = 'Kinsta';
2325 2347 }
2326 2348 } else {
2327 2349 $not_detected[] = 'Kinsta';
@@ -2716,9 +2738,9 @@
2716 2738 # Initialize MetaSync API request class and trigger synchronization
2717 2739 (new Metasync_Sync_Requests())->SyncCustomerParams();
2718 2740 } catch (Exception $e) {
2719 2741 # Log any API request errors for debugging
2720 - error_log('Metasync API Error: ' . $e->getMessage());
2742 + error_log('Metasync API Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2721 2743 }
2722 2744 }
2723 2745
2724 2746 /*
@@ -2733,9 +2755,9 @@
2733 2755 # Initialize MetaSync API request class and trigger synchronization
2734 2756 (new Metasync_Sync_Requests())->SyncCustomerParams();
2735 2757 } catch (Exception $e) {
2736 2758 # Log any API request errors for debugging
2737 - error_log('Metasync API Error: ' . $e->getMessage());
2759 + error_log('Metasync API Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2738 2760 }
2739 2761 }
2740 2762
2741 2763 /**
@@ -2772,9 +2794,9 @@
2772 2794 // Settings shape the whole site's conversion pipeline — restrict
2773 2795 // the save (and the reset) to site admins, not just anyone who can
2774 2796 // open the page via plugin_access_roles.
2775 2797 if (!current_user_can('manage_options')) {
2776 - wp_die(__('Sorry, you are not allowed to manage media optimization settings.', 'metasync'));
2798 + wp_die(esc_html__('Sorry, you are not allowed to manage media optimization settings.', 'metasync'));
2777 2799 }
2778 2800
2779 2801 // Handle reset to defaults
2780 2802 if (!empty($_POST['metasync_media_reset'])) {
@@ -2781,8 +2803,9 @@
2781 2803 $defaults = Metasync_Media_Settings::get_defaults();
2782 2804 Metasync_Media_Settings::save_settings($defaults);
2783 2805 $save_success = true;
2784 2806 } elseif (isset($_POST['metasync_media'])) {
2807 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Media_Settings::save_settings().
2785 2808 $input = wp_unslash($_POST['metasync_media']);
2786 2809 Metasync_Media_Settings::save_settings($input);
2787 2810 $save_success = true;
2788 2811 }
@@ -2814,8 +2837,15 @@
2814 2837 * Code Minification page callback
2815 2838 */
2816 2839 public function create_admin_code_minification_page()
2817 2840 {
2841 + // Settings shape the whole site's asset pipeline — restrict
2842 + // the save (and the reset) to site admins, not just anyone who can
2843 + // open the page via plugin_access_roles.
2844 + if (!empty($_POST) && !current_user_can('manage_options')) {
2845 + wp_die(esc_html__('Sorry, you are not allowed to manage code minification settings.', 'metasync'));
2846 + }
2847 +
2818 2848 $this->render_layout_open('Code Minification', 'code_minification', 'Minify CSS, JavaScript, and HTML to improve performance.');
2819 2849 // Load settings and compatibility classes
2820 2850 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-settings.php';
2821 2851 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-cache.php';
@@ -2832,8 +2862,9 @@
2832 2862 $defaults = Metasync_Minification_Settings::get_defaults();
2833 2863 Metasync_Minification_Settings::save_settings($defaults);
2834 2864 $save_success = true;
2835 2865 } elseif (isset($_POST['metasync_code_min'])) {
2866 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Minification_Settings::save_settings().
2836 2867 $input = (array) wp_unslash($_POST['metasync_code_min']);
2837 2868 Metasync_Minification_Settings::save_settings($input);
2838 2869 $save_success = true;
2839 2870 }
@@ -2888,8 +2919,9 @@
2888 2919 $max_bytes = 10 * 1024 * 1024;
2889 2920 if (filesize($file) > $max_bytes) {
2890 2921 $size_mb = round(filesize($file) / 1024 / 1024, 1);
2891 2922 wp_send_json_error(sprintf(
2923 + /* translators: %s: file size in megabytes. */
2892 2924 __('Optimization skipped: file size (%s MB) exceeds the 10 MB safety limit to prevent memory issues.', 'metasync'),
2893 2925 $size_mb
2894 2926 ));
2895 2927 }
@@ -3098,8 +3130,9 @@
3098 3130 if (!Metasync_Image_Converter::can_revert($id)) {
3099 3131 $skipped++;
3100 3132 $file = get_attached_file($id);
3101 3133 $name = $file ? basename($file) : "ID {$id}";
3134 + /* translators: %s: attachment file name. */
3102 3135 $errors[] = sprintf(__('%s: skipped — original image unavailable.', 'metasync'), $name);
3103 3136 continue;
3104 3137 }
3105 3138
@@ -3108,8 +3141,9 @@
3108 3141 } else {
3109 3142 $failed++;
3110 3143 $file = get_attached_file($id);
3111 3144 $name = $file ? basename($file) : "ID {$id}";
3145 + /* translators: %s: attachment file name. */
3112 3146 $errors[] = sprintf(__('%s: revert failed (original file may not exist).', 'metasync'), $name);
3113 3147 }
3114 3148 }
3115 3149
@@ -3194,8 +3228,86 @@
3194 3228 Metasync_Media_Batch_Optimizer::process_batch_tick();
3195 3229 }
3196 3230
3197 3231 /**
3232 + * AJAX: Start SEO Restore batch.
3233 + */
3234 + public function ajax_seo_restore_start()
3235 + {
3236 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3237 +
3238 + if (!current_user_can('manage_options')) {
3239 + wp_send_json_error(__('Permission denied.', 'metasync'));
3240 + }
3241 +
3242 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3243 +
3244 + $progress = Metasync_Seo_Restore::start_batch();
3245 + wp_send_json_success($progress);
3246 + }
3247 +
3248 + /**
3249 + * AJAX: Cancel SEO Restore batch.
3250 + */
3251 + public function ajax_seo_restore_cancel()
3252 + {
3253 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3254 +
3255 + if (!current_user_can('manage_options')) {
3256 + wp_send_json_error(__('Permission denied.', 'metasync'));
3257 + }
3258 +
3259 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3260 +
3261 + Metasync_Seo_Restore::cancel_batch();
3262 + wp_send_json_success();
3263 + }
3264 +
3265 + /**
3266 + * AJAX: Get SEO Restore batch progress.
3267 + */
3268 + public function ajax_seo_restore_progress()
3269 + {
3270 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3271 +
3272 + if (!current_user_can('manage_options')) {
3273 + wp_send_json_error(__('Permission denied.', 'metasync'));
3274 + }
3275 +
3276 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3277 +
3278 + $progress = Metasync_Seo_Restore::get_progress();
3279 + wp_send_json_success($progress);
3280 + }
3281 +
3282 + /**
3283 + * AJAX: Process one SEO Restore tick (browser-driven chaining).
3284 + */
3285 + public function ajax_seo_restore_process_tick()
3286 + {
3287 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3288 +
3289 + if (!current_user_can('manage_options')) {
3290 + wp_send_json_error(__('Permission denied.', 'metasync'));
3291 + }
3292 +
3293 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3294 +
3295 + $progress = Metasync_Seo_Restore::process_ajax_tick();
3296 + wp_send_json_success($progress);
3297 + }
3298 +
3299 + /**
3300 + * Cron handler: Process SEO Restore batch tick.
3301 + */
3302 + public function handle_seo_restore_cron()
3303 + {
3304 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3305 +
3306 + Metasync_Seo_Restore::process_batch_tick();
3307 + }
3308 +
3309 + /**
3198 3310 * Report Issue page callback
3199 3311 */
3200 3312 public function create_admin_report_issue_page()
3201 3313 {
@@ -3207,8 +3319,17 @@
3207 3319 * XML Sitemap page callback
3208 3320 */
3209 3321 public function create_admin_xml_sitemap_page()
3210 3322 {
3323 + // Every form action below mutates site-wide state — sitemap options,
3324 + // sitemap files on disk, robots.txt, other plugins' sitemap generators
3325 + // — so they are restricted to site admins, not just anyone who can
3326 + // open the page via plugin_access_roles. The nonce is CSRF
3327 + // protection, not authorization.
3328 + if (!empty($_POST) && !current_user_can('manage_options')) {
3329 + wp_die(esc_html__('Sorry, you are not allowed to manage sitemap settings.', 'metasync'));
3330 + }
3331 +
3211 3332 // Load sitemap generator class
3212 3333 require_once plugin_dir_path(dirname(__FILE__)) . 'sitemap/class-metasync-sitemap-generator.php';
3213 3334
3214 3335 $sitemap_generator = new Metasync_Sitemap_Generator();
@@ -3239,8 +3360,9 @@
3239 3360 // Regenerate sitemap with new content settings
3240 3361 $result = $sitemap_generator->generate_sitemap();
3241 3362 if (is_wp_error($result)) {
3242 3363 echo '<div class="notice notice-error"><p>' . esc_html(
3364 + /* translators: %s: error message. */
3243 3365 sprintf(__('Settings saved but sitemap generation failed: %s', 'metasync'), $result->get_error_message())
3244 3366 ) . '</p></div>';
3245 3367 } elseif (false === $result) {
3246 3368 echo '<div class="notice notice-error"><p>' . esc_html__('Settings saved but sitemap generation failed: the sitemap data could not be stored, so no sitemap is being served. Check your object cache and database write settings, then try again.', 'metasync') . '</p></div>';
@@ -3255,10 +3377,10 @@
3255 3377
3256 3378 // Build generic taxonomy filters
3257 3379 $news_taxonomies = [];
3258 3380 if (!empty($_POST['news_taxonomies']) && is_array($_POST['news_taxonomies'])) {
3259 - foreach ($_POST['news_taxonomies'] as $tax_name => $term_ids) {
3260 - $news_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) $term_ids);
3381 + foreach (wp_unslash($_POST['news_taxonomies']) as $tax_name => $term_ids) {
3382 + $news_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) wp_unslash($term_ids));
3261 3383 }
3262 3384 }
3263 3385
3264 3386 $news_settings = [
@@ -3283,9 +3405,9 @@
3283 3405 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3284 3406 } else {
3285 3407 // Also remove physical file if it exists
3286 3408 if (file_exists(ABSPATH . 'news-sitemap.xml')) {
3287 - @unlink(ABSPATH . 'news-sitemap.xml');
3409 + @wp_delete_file(ABSPATH . 'news-sitemap.xml');
3288 3410 }
3289 3411 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
3290 3412 }
3291 3413 }
@@ -3296,10 +3418,10 @@
3296 3418
3297 3419 // Build generic taxonomy filters
3298 3420 $video_taxonomies = [];
3299 3421 if (!empty($_POST['video_taxonomies']) && is_array($_POST['video_taxonomies'])) {
3300 - foreach ($_POST['video_taxonomies'] as $tax_name => $term_ids) {
3301 - $video_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) $term_ids);
3422 + foreach (wp_unslash($_POST['video_taxonomies']) as $tax_name => $term_ids) {
3423 + $video_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) wp_unslash($term_ids));
3302 3424 }
3303 3425 }
3304 3426
3305 3427 $video_settings = [
@@ -3325,9 +3447,9 @@
3325 3447 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3326 3448 } else {
3327 3449 // Also remove physical file if it exists
3328 3450 if (file_exists(ABSPATH . 'video-sitemap.xml')) {
3329 - @unlink(ABSPATH . 'video-sitemap.xml');
3451 + @wp_delete_file(ABSPATH . 'video-sitemap.xml');
3330 3452 }
3331 3453 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
3332 3454 }
3333 3455 }
@@ -3395,10 +3517,11 @@
3395 3517 $result = $sitemap_generator->generate_sitemap();
3396 3518
3397 3519 if (is_wp_error($result)) {
3398 3520 $error_msg = $result->get_error_message();
3399 - error_log('[MetaSync] Sitemap generation failed: ' . $error_msg);
3521 + error_log('[MetaSync] Sitemap generation failed: ' . $error_msg); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3400 3522 echo '<div class="notice notice-error"><p>' . esc_html(
3523 + /* translators: %s: error message. */
3401 3524 sprintf(__('Sitemap generation failed: %s', 'metasync'), $error_msg)
3402 3525 ) . '</p></div>';
3403 3526 } elseif (false === $result) {
3404 3527 echo '<div class="notice notice-error"><p>' . esc_html__('Sitemap generation failed: the sitemap data could not be stored, so no sitemap is being served. Check your object cache and database write settings, then try again.', 'metasync') . '</p></div>';
@@ -3405,8 +3528,9 @@
3405 3528 } else {
3406 3529 $message = esc_html__('Sitemap generated successfully!', 'metasync');
3407 3530 if (!empty($extras)) {
3408 3531 $message .= ' ' . sprintf(
3532 + /* translators: %s: list of sitemap types. */
3409 3533 esc_html__('Also generated %s sitemap(s).', 'metasync'),
3410 3534 implode(' & ', $extras)
3411 3535 );
3412 3536 }
@@ -3595,48 +3719,8 @@
3595 3719 Metasync_Admin_Pages::get_instance($this)->create_admin_breadcrumbs_page();
3596 3720 }
3597 3721
3598 3722 /**
3599 - * Google Instant Index Setting page callback
3600 - */
3601 - public function create_admin_google_instant_index_page()
3602 - {
3603 - $this->render_layout_open('Instant Indexing', 'instant_index', 'Submit URLs to Google for instant indexing via the Indexing API.');
3604 -
3605 - // Render shared Google Index credentials section
3606 - if (!function_exists('google_index_direct')) {
3607 - if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) {
3608 - require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
3609 - } else {
3610 - error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php');
3611 - return;
3612 - }
3613 - }
3614 - $google_index = google_index_direct();
3615 - $service_info = $google_index->get_service_account_info();
3616 - $is_configured = !isset($service_info['error']);
3617 -
3618 - $saved_json_display = $is_configured ? $google_index->get_redacted_config_json() : '';
3619 -
3620 - include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-index-api-settings.php';
3621 -
3622 - // Render post types selection with save form
3623 - $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
3624 - $post_types_settings = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
3625 - ?>
3626 - <form method="POST" action="">
3627 - <?php wp_nonce_field('metasync_instant_indexing_settings', 'metasync_instant_indexing_nonce'); ?>
3628 - <?php include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-instant-post-types.php'; ?>
3629 - <div class="dashboard-card" style="padding: 20px;">
3630 - <?php submit_button('Save Post Types', 'primary', 'submit', false, array('class' => 'button button-primary')); ?>
3631 - </div>
3632 - </form>
3633 - <?php
3634 -
3635 - $this->render_layout_close();
3636 - }
3637 -
3638 - /**
3639 3723 * Google Console page callback
3640 3724 */
3641 3725 public function create_admin_google_console_page()
3642 3726 {
@@ -3742,8 +3826,9 @@
3742 3826 if (!get_transient('metasync_llms_conflict')) {
3743 3827 return;
3744 3828 }
3745 3829 echo '<div class="notice notice-info is-dismissible"><p>';
3830 + /* translators: %s: effective plugin name (whitelabel-aware). */
3746 3831 echo esc_html(sprintf(__('Note: Another SEO plugin (Yoast, Rank Math, or AIOSEO) may also be generating /llms.txt. %s\'s version takes priority when enabled.', 'metasync'), Metasync::get_effective_plugin_name()));
3747 3832 echo '</p></div>';
3748 3833 }
3749 3834
@@ -3935,9 +4020,9 @@
3935 4020 return;
3936 4021 }
3937 4022
3938 4023 // Get pagination parameters
3939 - $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
4024 + $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
3940 4025 $per_page = Metasync_Per_Page_Helper::resolve('sync_log', 10);
3941 4026 $offset = ($page - 1) * $per_page;
3942 4027
3943 4028 // Get filters
@@ -3942,10 +4027,10 @@
3942 4027
3943 4028 // Get filters
3944 4029 $filters = [
3945 4030 // UI exposes date_range and status only. We compute date_from/date_to based on date_range
3946 - 'date_range' => isset($_GET['date_range']) ? sanitize_text_field(wp_unslash($_GET['date_range'])) : '',
3947 - 'status' => isset($_GET['status']) ? sanitize_text_field(wp_unslash($_GET['status'])) : '',
4031 + 'date_range' => isset($_GET['date_range']) ? sanitize_text_field(wp_unslash($_GET['date_range'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
4032 + 'status' => isset($_GET['status']) ? sanitize_text_field(wp_unslash($_GET['status'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
3948 4033 ];
3949 4034
3950 4035 // Map date_range to concrete date_from/date_to for DB queries
3951 4036 $date_range = $filters['date_range'];
@@ -3954,28 +4039,28 @@
3954 4039 $date_to = '';
3955 4040
3956 4041 if (!empty($date_range)) {
3957 4042 // End boundary is now by default
3958 - $date_to = date('Y-m-d H:i:s', $wp_now_ts);
4043 + $date_to = gmdate('Y-m-d H:i:s', $wp_now_ts);
3959 4044
3960 4045 if ($date_range === 'today') {
3961 4046 $start_ts = strtotime('today', $wp_now_ts);
3962 - $date_from = date('Y-m-d H:i:s', $start_ts);
4047 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3963 4048 } elseif ($date_range === 'yesterday') {
3964 4049 $start_ts = strtotime('yesterday', $wp_now_ts);
3965 4050 $end_ts = strtotime('today', $wp_now_ts) - 1; // end of yesterday
3966 - $date_from = date('Y-m-d H:i:s', $start_ts);
3967 - $date_to = date('Y-m-d H:i:s', $end_ts);
4051 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
4052 + $date_to = gmdate('Y-m-d H:i:s', $end_ts);
3968 4053 } elseif ($date_range === 'this_week') {
3969 4054 $start_of_week = (int) get_option('start_of_week', 1); // 0=Sun, 1=Mon
3970 - $day_of_week = (int) date('w', $wp_now_ts); // 0=Sun..6=Sat
4055 + $day_of_week = (int) gmdate('w', $wp_now_ts); // 0=Sun..6=Sat
3971 4056 // Convert start_of_week to PHP's 0..6 where 0=Sunday
3972 4057 $delta_days = ($day_of_week - $start_of_week + 7) % 7;
3973 4058 $start_ts = strtotime('-' . $delta_days . ' days', strtotime('today', $wp_now_ts));
3974 - $date_from = date('Y-m-d H:i:s', $start_ts);
4059 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3975 4060 } elseif ($date_range === 'this_month') {
3976 - $start_ts = strtotime(date('Y-m-01 00:00:00', $wp_now_ts));
3977 - $date_from = date('Y-m-d H:i:s', $start_ts);
4061 + $start_ts = strtotime(gmdate('Y-m-01 00:00:00', $wp_now_ts));
4062 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3978 4063 } elseif ($date_range === 'all') {
3979 4064 // no bounds
3980 4065 }
3981 4066 }
@@ -4002,12 +4087,24 @@
4002 4087 ?>
4003 4088 <div class="dashboard-card">
4004 4089 <div class="sync-log-header">
4005 4090 <div class="sync-log-title-section">
4006 - <h2>Changes Log</h2>
4007 - <p style="color: var(--dashboard-text-secondary); margin-bottom: 0;">
4008 - Recent content synchronizations from external tools.
4009 - <span style="margin-left:8px; font-size:12px; opacity:.75;">Records are automatically removed after 90 days.</span>
4091 + <?php
4092 + // The page title and description are already rendered by
4093 + // render_layout_open() above. Repeating them here printed
4094 + // "Changes Log" twice on screen, so the card keeps only the
4095 + // retention hint — the one piece of copy the layout header
4096 + // does not carry.
4097 + ?>
4098 + <p style="color: var(--dashboard-text-secondary); margin-top: 0; margin-bottom: 0;">
4099 + <?php
4100 + // No opacity here: this used to be a de-emphasised
4101 + // aside trailing a full-contrast sentence, but it is
4102 + // now the card's only copy. At 12px/.75 it composites
4103 + // to 4.28:1 on the card background, under the 4.5:1
4104 + // AA floor; at full strength it reads 6.52:1.
4105 + ?>
4106 + <span style="font-size:13px;">Records are automatically removed after 90 days.</span>
4010 4107 </p>
4011 4108 </div>
4012 4109
4013 4110 <!-- Filters + Clear Log button - Right aligned -->
@@ -4017,9 +4114,9 @@
4017 4114 data-nonce="<?php echo esc_attr(wp_create_nonce('metasync_clear_sync_log')); ?>">
4018 4115 🗑 Clear Log
4019 4116 </button>
4020 4117 <form method="get" class="sync-filters-form" onchange="this.submit()" style="display:flex;flex-direction:row;align-items:center;gap:12px;flex-wrap:nowrap;">
4021 - <input type="hidden" name="page" value="<?php echo esc_attr($_GET['page']); ?>">
4118 + <input type="hidden" name="page" value="<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>">
4022 4119
4023 4120 <select name="date_range" class="sync-filter-select">
4024 4121 <option value="all" <?php selected($filters['date_range'] ?? 'all', 'all'); ?>> All Time</option>
4025 4122 <option value="today" <?php selected($filters['date_range'] ?? '', 'today'); ?>>Today</option>
@@ -4041,9 +4138,9 @@
4041 4138 <div class="sync-log-list">
4042 4139 <?php if (empty($sync_records)): ?>
4043 4140 <div class="sync-log-empty">
4044 4141 <div class="sync-log-empty-icon"><span class="dashicons dashicons-media-default" style="font-size:48px;width:48px;height:48px;color:var(--dashboard-text-secondary);"></span></div>
4045 - <h3>No sync records found</h3>
4142 + <h2>No sync records found</h2>
4046 4143 <p>Sync records will appear here when content/pages receive new updates.</p>
4047 4144 </div>
4048 4145 <?php else: ?>
4049 4146 <?php foreach ($sync_records as $record): ?>
@@ -4100,24 +4197,25 @@
4100 4197 <!-- Pagination -->
4101 4198 <div class="sync-log-pagination">
4102 4199 <div class="sync-log-pagination-info">
4103 4200 Total records: <?php echo intval( $total_records ); ?><?php if ($total_records > 0): ?> | Showing <?php echo intval( $offset ) + 1; ?>-<?php echo intval( min($offset + $per_page, $total_records) ); ?><?php endif; ?>
4104 - <?php echo Metasync_Per_Page_Helper::render_selector('sync_log', $per_page); ?>
4201 + <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper returns a complete escaped selector element.
4202 + echo Metasync_Per_Page_Helper::render_selector('sync_log', absint($per_page)); ?>
4105 4203 </div>
4106 4204
4107 4205 <?php if ($total_pages > 1): ?>
4108 4206 <div class="sync-log-pagination-controls">
4109 4207 <?php if ($page > 1): ?>
4110 - <a href="?page=<?php echo esc_attr($_GET['page']); ?>&paged=<?php echo intval( $page ) - 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">‹</a>
4208 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $page ) - 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">‹</a>
4111 4209 <?php endif; ?>
4112 4210
4113 4211 <?php for ($i = max(1, $page - 2); $i <= min($total_pages, $page + 2); $i++): ?>
4114 - <a href="?page=<?php echo esc_attr($_GET['page']); ?>&paged=<?php echo intval( $i ); ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>"
4212 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $i ); ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>"
4115 4213 class="sync-pagination-btn <?php echo $i === $page ? 'active' : ''; ?>"><?php echo intval( $i ); ?></a>
4116 4214 <?php endfor; ?>
4117 4215
4118 4216 <?php if ($page < $total_pages): ?>
4119 - <a href="?page=<?php echo esc_attr($_GET['page']); ?>&paged=<?php echo intval( $page ) + 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">›</a>
4217 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $page ) + 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">›</a>
4120 4218 <?php endif; ?>
4121 4219 </div>
4122 4220 <?php endif; ?>
4123 4221 </div>
@@ -4206,10 +4304,10 @@
4206 4304
4207 4305 // Preserve the user-selected results-per-page value across page
4208 4306 // navigation so a non-default page size survives clicking a page link.
4209 4307 $per_page_key = Metasync_Per_Page_Helper::request_key('sync_log');
4210 - if (isset($_GET[$per_page_key])) {
4211 - $per_page = (int) $_GET[$per_page_key];
4308 + if (isset($_GET[$per_page_key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4309 + $per_page = (int) $_GET[$per_page_key]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4212 4310 if (in_array($per_page, Metasync_Per_Page_Helper::allowed_values(), true)) {
4213 4311 $query_parts[] = $per_page_key . '=' . $per_page;
4214 4312 }
4215 4313 }
@@ -4388,18 +4486,8 @@
4388 4486 {
4389 4487 Metasync_Connect_Manager::instance()->handle_session_management_early();
4390 4488 }
4391 4489
4392 - /**
4393 - * @deprecated 2.5.12 Use Metasync_Auth_Manager instead of sessions for authentication
4394 - */
4395 - private function safe_session_start() {
4396 - // This method is deprecated and no longer used
4397 - // Authentication now uses Metasync_Auth_Manager with WordPress transients and user meta
4398 - _deprecated_function(__METHOD__, '2.5.12', 'Metasync_Auth_Manager');
4399 - return Metasync_Session_Helper::safe_start();
4400 - }
4401 -
4402 4490 private function handle_whitelabel_session_logic()
4403 4491 {
4404 4492 Metasync_Connect_Manager::instance()->handle_whitelabel_session_logic();
4405 4493 }
@@ -4495,9 +4583,9 @@
4495 4583 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4496 4584 Effective Threshold
4497 4585 </label>
4498 4586 <div style="padding: 10px 12px; background: var(--dashboard-input-bg); border: 1px solid var(--dashboard-border); border-radius: 6px; color: var(--dashboard-text-secondary);">
4499 - <strong id="effective_threshold_value"><?php echo round($effective_threshold, 2); ?></strong>
4587 + <strong id="effective_threshold_value"><?php echo esc_html((string) round($effective_threshold, 2)); ?></strong>
4500 4588 </div>
4501 4589 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
4502 4590 Calculated as: cores × per-core threshold
4503 4591 </p>
@@ -4515,13 +4603,13 @@
4515 4603 <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo intval($stats['deferrals']); ?></div>
4516 4604 </div>
4517 4605 <div>
4518 4606 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Max Load Observed</div>
4519 - <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo round($stats['max_load'], 2); ?></div>
4607 + <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo esc_html((string) round($stats['max_load'], 2)); ?></div>
4520 4608 </div>
4521 4609 <div>
4522 4610 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Average Load</div>
4523 - <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo round($stats['avg_load'], 2); ?></div>
4611 + <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo esc_html((string) round($stats['avg_load'], 2)); ?></div>
4524 4612 </div>
4525 4613 </div>
4526 4614 </div>
4527 4615
@@ -5228,9 +5316,9 @@
5228 5316 */
5229 5317 public function display_page_builder_notice() {
5230 5318 // Only relevant on the plugin's own settings page — avoid repeating
5231 5319 // this notice across every admin screen.
5232 - if (!isset($_GET['page']) || $_GET['page'] !== self::$page_slug) {
5320 + if (!isset($_GET['page']) || wp_unslash($_GET['page']) !== self::$page_slug) {
5233 5321 return;
5234 5322 }
5235 5323
5236 5324 $configured = Metasync::get_option('general')['default_page_builder'] ?? '';
@@ -5246,9 +5334,9 @@
5246 5334 return;
5247 5335 }
5248 5336
5249 5337 // Handle dismiss action
5250 - if (isset($_GET['metasync_dismiss_builder_notice']) && wp_verify_nonce($_GET['_wpnonce'] ?? '', 'metasync_dismiss_builder')) {
5338 + if (isset($_GET['metasync_dismiss_builder_notice'], $_GET['_wpnonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'metasync_dismiss_builder')) {
5251 5339 update_user_meta(get_current_user_id(), 'metasync_builder_notice_dismissed', '1');
5252 5340 return;
5253 5341 }
5254 5342
@@ -5343,9 +5431,10 @@
5343 5431 * Saves the CPU load threshold and returns statistics
5344 5432 */
5345 5433 public function ajax_save_performance_settings() {
5346 5434 # Check nonce for security and return early if invalid
5347 - if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce($_POST['meta_sync_nonce'], 'meta_sync_general_setting_nonce')) {
5435 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5436 + if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce(wp_unslash($_POST['meta_sync_nonce']), 'meta_sync_general_setting_nonce')) {
5348 5437 wp_send_json_error(array('message' => 'Invalid nonce'));
5349 5438 return;
5350 5439 }
5351 5440
@@ -5413,9 +5502,9 @@
5413 5502 if (!wp_next_scheduled('metasync_cleanup_transients')) {
5414 5503 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_cleanup_transients');
5415 5504
5416 5505 if (!$scheduled) {
5417 - error_log('MetaSync: Failed to schedule transient cleanup cron job');
5506 + error_log('MetaSync: Failed to schedule transient cleanup cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5418 5507 }
5419 5508 }
5420 5509 }
5421 5510
@@ -5426,9 +5515,9 @@
5426 5515 {
5427 5516 $timestamp = wp_next_scheduled('metasync_cleanup_transients');
5428 5517 if ($timestamp) {
5429 5518 wp_unschedule_event($timestamp, 'metasync_cleanup_transients');
5430 - error_log('MetaSync: Transient cleanup cron job unscheduled');
5519 + error_log('MetaSync: Transient cleanup cron job unscheduled'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- unreachable in production
5431 5520 }
5432 5521 }
5433 5522
5434 5523 /**
@@ -5450,12 +5539,10 @@
5450 5539 {
5451 5540 if (!wp_next_scheduled('metasync_hidden_post_check')) {
5452 5541 $scheduled = wp_schedule_event(time(), 'metasync_weekly', 'metasync_hidden_post_check');
5453 5542
5454 - if ($scheduled) {
5455 - error_log('MetaSync: Hidden post manager cron job scheduled successfully (runs every 7 days)');
5456 - } else {
5457 - error_log('MetaSync: Failed to schedule hidden post manager cron job');
5543 + if (!$scheduled) {
5544 + error_log('MetaSync: Failed to schedule hidden post manager cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5458 5545 }
5459 5546 }
5460 5547 }
5461 5548
@@ -5466,12 +5553,10 @@
5466 5553 public function maybe_schedule_otto_recheck_404_cron()
5467 5554 {
5468 5555 if (!wp_next_scheduled('metasync_otto_recheck_404_exclusions')) {
5469 5556 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_otto_recheck_404_exclusions');
5470 - if ($scheduled) {
5471 - error_log('MetaSync: OTTO 404 recheck cron job scheduled successfully (runs daily)');
5472 - } else {
5473 - error_log('MetaSync: Failed to schedule OTTO 404 recheck cron job');
5557 + if (!$scheduled) {
5558 + error_log('MetaSync: Failed to schedule OTTO 404 recheck cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5474 5559 }
5475 5560 }
5476 5561 }
5477 5562
@@ -5544,13 +5629,13 @@
5544 5629
5545 5630 try {
5546 5631 // 1. Post revisions
5547 5632 if (!empty($settings['clean_post_revisions'])) {
5548 - $stats['post_revisions'] = (int) $wpdb->query(
5633 + $stats['post_revisions'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5549 5634 "DELETE FROM {$wpdb->posts} WHERE post_type = 'revision'"
5550 5635 );
5551 5636 // Remove postmeta left behind by deleted revisions
5552 - $wpdb->query(
5637 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5553 5638 "DELETE pm FROM {$wpdb->postmeta} pm
5554 5639 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5555 5640 WHERE p.ID IS NULL"
5556 5641 );
@@ -5558,20 +5643,19 @@
5558 5643
5559 5644 // 2. Trashed posts + their postmeta
5560 5645 if (!empty($settings['clean_trashed_posts'])) {
5561 5646 // Collect IDs first to cleanly remove postmeta
5562 - $trashed_ids = $wpdb->get_col(
5647 + $trashed_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5563 5648 "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'trash'"
5564 5649 );
5565 5650 if (!empty($trashed_ids)) {
5566 - $placeholders = implode(',', array_fill(0, count($trashed_ids), '%d'));
5567 - $wpdb->query(
5651 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5568 5652 $wpdb->prepare(
5569 - "DELETE FROM {$wpdb->postmeta} WHERE post_id IN ($placeholders)",
5653 + 'DELETE FROM ' . $wpdb->postmeta . ' WHERE post_id IN (' . implode(',', array_fill(0, count($trashed_ids), '%d')) . ')',
5570 5654 $trashed_ids
5571 5655 )
5572 5656 );
5573 - $stats['trashed_posts'] = (int) $wpdb->query(
5657 + $stats['trashed_posts'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5574 5658 "DELETE FROM {$wpdb->posts} WHERE post_status = 'trash'"
5575 5659 );
5576 5660 } else {
5577 5661 $stats['trashed_posts'] = 0;
@@ -5579,9 +5663,9 @@
5579 5663 }
5580 5664
5581 5665 // 3. Trashed comments
5582 5666 if (!empty($settings['clean_trashed_comments'])) {
5583 - $stats['trashed_comments'] = (int) $wpdb->query(
5667 + $stats['trashed_comments'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5584 5668 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'trash'"
5585 5669 );
5586 5670 }
5587 5671
@@ -5586,9 +5670,9 @@
5586 5670 }
5587 5671
5588 5672 // 4. Spam comments
5589 5673 if (!empty($settings['clean_spam_comments'])) {
5590 - $stats['spam_comments'] = (int) $wpdb->query(
5674 + $stats['spam_comments'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5591 5675 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'spam'"
5592 5676 );
5593 5677 }
5594 5678
@@ -5594,15 +5678,15 @@
5594 5678
5595 5679 // 5. Expired transients — direct SQL, no cache flush
5596 5680 if (!empty($settings['clean_expired_transients'])) {
5597 5681 // Delete timeout rows that have already expired
5598 - $wpdb->query(
5682 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5599 5683 "DELETE FROM {$wpdb->options}
5600 5684 WHERE option_name LIKE '\_transient\_timeout\_%'
5601 5685 AND option_value + 0 < UNIX_TIMESTAMP()"
5602 5686 );
5603 5687 // Delete value rows whose timeout row no longer exists
5604 - $stats['expired_transients'] = (int) $wpdb->query(
5688 + $stats['expired_transients'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5605 5689 "DELETE o FROM {$wpdb->options} o
5606 5690 LEFT JOIN {$wpdb->options} t
5607 5691 ON t.option_name = CONCAT('_transient_timeout_', SUBSTRING(o.option_name, 12))
5608 5692 WHERE o.option_name LIKE '\_transient\_%'
@@ -5612,9 +5696,9 @@
5612 5696 }
5613 5697
5614 5698 // 6. Orphaned postmeta (post_id references a post that no longer exists)
5615 5699 if (!empty($settings['clean_orphaned_postmeta'])) {
5616 - $stats['orphaned_postmeta'] = (int) $wpdb->query(
5700 + $stats['orphaned_postmeta'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5617 5701 "DELETE pm FROM {$wpdb->postmeta} pm
5618 5702 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5619 5703 WHERE p.ID IS NULL"
5620 5704 );
@@ -5626,12 +5710,10 @@
5626 5710 $settings['last_run_at'] = time();
5627 5711 $settings['last_run_stats'] = $stats;
5628 5712 update_option('metasync_db_cleanup_settings', $settings);
5629 5713
5630 - error_log('MetaSync: DB cleanup completed — ' . json_encode($stats));
5631 -
5632 5714 } catch (Exception $e) {
5633 - error_log('MetaSync: DB cleanup failed — ' . $e->getMessage());
5715 + error_log('MetaSync: DB cleanup failed — ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5634 5716 }
5635 5717 }
5636 5718
5637 5719 /**
@@ -5848,10 +5930,11 @@
5848 5930 /**
5849 5931 * AJAX: Save DB cleanup settings and reschedule cron accordingly.
5850 5932 */
5851 5933 public function ajax_save_db_cleanup_settings() {
5934 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5852 5935 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5853 - !wp_verify_nonce($_POST['db_cleanup_settings_nonce'], 'metasync_db_cleanup_settings_nonce')) {
5936 + !wp_verify_nonce(wp_unslash($_POST['db_cleanup_settings_nonce']), 'metasync_db_cleanup_settings_nonce')) {
5854 5937 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
5855 5938 return;
5856 5939 }
5857 5940
@@ -5908,10 +5991,11 @@
5908 5991 * AJAX: Manually trigger the DB cleanup and return stats for the UI.
5909 5992 * Persists current form state first so unsaved checkbox changes are respected.
5910 5993 */
5911 5994 public function ajax_run_db_cleanup() {
5995 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5912 5996 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5913 - !wp_verify_nonce($_POST['db_cleanup_settings_nonce'], 'metasync_db_cleanup_settings_nonce')) {
5997 + !wp_verify_nonce(wp_unslash($_POST['db_cleanup_settings_nonce']), 'metasync_db_cleanup_settings_nonce')) {
5914 5998 wp_send_json_error(array('message' => 'Invalid security token.'));
5915 5999 return;
5916 6000 }
5917 6001
@@ -5987,35 +6071,8 @@
5987 6071 ));
5988 6072 }
5989 6073
5990 6074 /**
5991 - * Control plugin auto-updates based on user setting
5992 - *
5993 - * @param bool $update Whether to update
5994 - * @param object $item Update offer
5995 - * @return bool Whether to allow auto-update
5996 - */
5997 - public function control_plugin_auto_updates($update, $item)
5998 - {
5999 - # Check if the item object has the slug property
6000 - if (!isset($item->slug)) {
6001 - return $update;
6002 - }
6003 -
6004 - // Check if this is our plugin
6005 - if ($item->slug === 'metasync') {
6006 - $general_settings = Metasync::get_option('general') ?? [];
6007 - $enable_auto_updates = $general_settings['enable_auto_updates'] ?? false;
6008 -
6009 - // Return the user's preference (true = allow auto-updates, false = prevent)
6010 - return $enable_auto_updates === 'true' || $enable_auto_updates === true;
6011 - }
6012 -
6013 - // For other plugins, don't interfere with their auto-update settings
6014 - return $update;
6015 - }
6016 -
6017 - /**
6018 6075 * AJAX handler to add excluded URL for OTTO
6019 6076 */
6020 6077 public function ajax_otto_add_excluded_url()
6021 6078 {
@@ -6161,8 +6218,9 @@
6161 6218 // If page has raw HTML or custom CSS from conversion, show badge
6162 6219 if ($has_raw_html || !empty($has_custom_css)) {
6163 6220 $label = $this->get_html_source_label();
6164 6221 $tooltip = sprintf(
6222 + /* translators: %s: HTML source label. */
6165 6223 __('This page was created using %s HTML-to-Builder converter', 'metasync'),
6166 6224 $label
6167 6225 );
6168 6226
@@ -6215,8 +6273,9 @@
6215 6273
6216 6274 if ($has_raw_html || !empty($has_custom_css)) {
6217 6275 $label = $this->get_html_source_label();
6218 6276 $message = sprintf(
6277 + /* translators: %s: HTML source label. */
6219 6278 __('This page was created using %s HTML-to-Builder converter. The design is preserved with custom CSS and inline styles.', 'metasync'),
6220 6279 '<strong>' . esc_html($label) . '</strong>'
6221 6280 );
6222 6281
@@ -6230,9 +6289,9 @@
6230 6289 <p class="metasync-editor-message">%s</p>
6231 6290 </div>
6232 6291 </div>',
6233 6292 esc_html($label),
6234 - $message
6293 + wp_kses($message, array('strong' => array()))
6235 6294 );
6236 6295 }
6237 6296 }
6238 6297
@@ -6255,9 +6314,9 @@
6255 6314 ?>
6256 6315 <fieldset class="inline-edit-col-left metasync-quick-edit-source">
6257 6316 <div class="inline-edit-col">
6258 6317 <label>
6259 - <span class="title"><?php _e('Source', 'metasync'); ?></span>
6318 + <span class="title"><?php esc_html_e('Source', 'metasync'); ?></span>
6260 6319 <span class="metasync-quick-edit-badge-container"></span>
6261 6320 </label>
6262 6321 </div>
6263 6322 </fieldset>
@@ -6269,8 +6328,9 @@
6269 6328 */
6270 6329 public function add_html_pages_dashboard_widget()
6271 6330 {
6272 6331 $label = $this->get_html_source_label();
6332 + /* translators: %s: HTML source label. */
6273 6333 $widget_title = sprintf(__('%s Pages', 'metasync'), $label);
6274 6334
6275 6335 wp_add_dashboard_widget(
6276 6336 'metasync_html_pages_widget',