PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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 +317 -283 2.6.24 → 2.7.2 View file →
@@ -41,9 +41,8 @@
41 41 const SECTION_SEO_CONTROLS = "seo_controls";
42 42 const SECTION_SEO_CONTROLS_ADVANCED = "seo_controls_advanced";
43 43 const SECTION_SEO_CONTROLS_INSTANT_INDEX = "seo_controls_instant_index";
44 44 const SECTION_PLUGIN_VISIBILITY = "plugin_visibility_settings";
45 - const SECTION_BREADCRUMBS = "breadcrumbs_settings";
46 45 const SECTION_LLMS_TXT = "llms_txt_settings";
47 46
48 47 /**
49 48 * The ID of this plugin.
@@ -213,9 +212,8 @@
213 212 add_action('admin_menu', array($this, 'add_import_external_data_page'));
214 213 add_action('admin_init', array($this, 'settings_page_init'));
215 214 add_filter('all_plugins', array($this,'metasync_plugin_white_label'));
216 215 add_filter( 'plugin_row_meta',array($this,'metasync_view_detials_url'),10,3);
217 - add_filter('site_transient_update_plugins', array($this, 'inject_whitelabel_icon_into_update_transient'));
218 216
219 217 // Display transient error/success messages for redirections
220 218 add_action('admin_notices', array($this, 'display_redirection_messages'));
221 219
@@ -265,8 +263,15 @@
265 263
266 264 add_action('admin_init', array($this, 'initialize_cookie'));
267 265 add_action('admin_init', array($this, 'maybe_redirect_to_wizard'));
268 266
267 + // The redirect must run on admin_menu, not admin_init: in current
268 + // WordPress the unregistered-page access check in wp-admin/includes/
269 + // menu.php fires from wp-admin/menu.php (required at admin.php:163),
270 + // which is BEFORE admin_init (admin.php:180). Hooked on admin_init the
271 + // redirect never ran — WP died with a 403 first for the removed slug.
272 + add_action('admin_menu', array($this, 'redirect_legacy_instant_indexing_page'));
273 +
269 274 // Add admin_post hooks for form submissions (WordPress standard way - no output buffering needed)
270 275 add_action('admin_post_metasync_clear_all_cache_plugins', array($this, 'handle_clear_all_cache_plugins'));
271 276 add_action('admin_post_metasync_clear_otto_cache_all', array($this, 'handle_clear_otto_cache_all'));
272 277 add_action('admin_post_metasync_clear_otto_cache_url', array($this, 'handle_clear_otto_cache_url'));
@@ -283,8 +288,11 @@
283 288
284 289 // Add AJAX for saving execution settings
285 290 add_action( 'wp_ajax_metasync_save_execution_settings', array($this, 'ajax_save_execution_settings') );
286 291
292 + // Add AJAX for saving the Headless Mode demo controls on the Advanced tab
293 + add_action( 'wp_ajax_metasync_save_headless_settings', array(Metasync_Settings_Registration::instance(), 'ajax_save_headless_settings') );
294 +
287 295 // Add AJAX for saving hosting cache settings
288 296 add_action( 'wp_ajax_metasync_save_hosting_cache_settings', array($this, 'ajax_save_hosting_cache_settings') );
289 297
290 298 // Add AJAX for saving OTTO Cache TTL
@@ -373,8 +381,15 @@
373 381 add_action('wp_ajax_metasync_process_batch_tick', array($this, 'ajax_process_batch_tick'));
374 382 add_action('wp_ajax_metasync_delete_orphaned_image', array($this, 'ajax_delete_orphaned_image'));
375 383 add_action('metasync_media_batch_optimize_cron', array($this, 'handle_media_batch_cron'));
376 384
385 + # SEO Restore (Rollback) AJAX & Cron handlers
386 + add_action('wp_ajax_metasync_seo_restore_start', array($this, 'ajax_seo_restore_start'));
387 + add_action('wp_ajax_metasync_seo_restore_cancel', array($this, 'ajax_seo_restore_cancel'));
388 + add_action('wp_ajax_metasync_seo_restore_progress', array($this, 'ajax_seo_restore_progress'));
389 + add_action('wp_ajax_metasync_seo_restore_process_tick', array($this, 'ajax_seo_restore_process_tick'));
390 + add_action('metasync_seo_restore_cron', array($this, 'handle_seo_restore_cron'));
391 +
377 392 # Add AJAX handlers for Google Instant Indexing
378 393 add_action('wp_ajax_metasync_send_giapi', array($this, 'ajax_send_giapi'));
379 394
380 395 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
@@ -379,11 +394,8 @@
379 394
380 395 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
381 396 add_action('wp_ajax_metasync_send_bing_indexnow', array($this, 'ajax_send_bing_indexnow'));
382 397
383 - # Add hooks for instant indexing settings saves
384 - add_action('admin_init', array($this, 'save_instant_indexing_settings'));
385 -
386 398 # Add hooks for instant indexing post actions
387 399 add_filter('post_row_actions', array($this, 'add_instant_indexing_post_actions'), 10, 2);
388 400 add_filter('page_row_actions', array($this, 'add_instant_indexing_post_actions'), 10, 2);
389 401
@@ -488,9 +500,9 @@
488 500 <?php
489 501 }
490 502
491 503 public function metasync_fouc_prevention_style() {
492 - if ( ! isset( $_GET['page'] ) || strpos( $_GET['page'], self::$page_slug ) !== 0 ) {
504 + 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
493 505 return;
494 506 }
495 507 ?>
496 508 <style>
@@ -512,13 +524,13 @@
512 524 <?php
513 525 }
514 526
515 527 public function suppress_notices_on_wizard_page() {
516 - if ( ! isset( $_GET['page'] ) ) {
528 + if ( ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
517 529 return;
518 530 }
519 531
520 - $page = sanitize_text_field( wp_unslash( $_GET['page'] ) );
532 + $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
521 533
522 534 // Setup wizard: strip every notice for a fully focused, distraction-free screen.
523 535 if ( strpos( $page, '-setup-wizard' ) !== false ) {
524 536 remove_all_actions( 'admin_notices' );
@@ -636,9 +648,9 @@
636 648
637 649 #Redirect url to the new slug
638 650 $redirect_url = admin_url('admin.php?page=' . $current_slug);
639 651
640 - wp_redirect($redirect_url);
652 + wp_safe_redirect($redirect_url);
641 653 exit;
642 654 }
643 655 }
644 656
@@ -690,13 +702,11 @@
690 702 * @since 1.0.0
691 703 */
692 704 public function maybe_redirect_to_wizard() {
693 705 // Only redirect if wizard should be shown
694 - if (get_option('metasync_show_wizard') && !isset($_GET['page'])) {
695 - delete_option('metasync_show_wizard');
696 -
706 + 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
697 707 // Don't redirect during AJAX, cron, or bulk activation
698 - if (wp_doing_ajax() || wp_doing_cron() || isset($_GET['activate-multi'])) {
708 + 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
699 709 return;
700 710 }
701 711
702 712 // Only redirect if user has access to the plugin
@@ -703,16 +713,66 @@
703 713 if (!Metasync::current_user_has_plugin_access()) {
704 714 return;
705 715 }
706 716
717 + delete_option('metasync_show_wizard');
707 718 wp_safe_redirect(admin_url('admin.php?page=' . self::$page_slug . '-setup-wizard'));
708 719 exit;
709 720 }
710 721 }
711 722
723 + /**
724 + * Redirect the removed standalone Instant Indexing page to Indexation Control.
725 + *
726 + * The menu entry is gone, but bookmarks and external links can still target
727 + * the old URL. Resolve the current white-label slug rather than assuming
728 + * "searchatlas".
729 + *
730 + * Hooked on admin_menu (see its registration): the unregistered-page 403 in
731 + * wp-admin/includes/menu.php fires before admin_init, so any later hook
732 + * would be too late to redirect.
733 + */
734 + public function redirect_legacy_instant_indexing_page() {
735 + 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
736 + return;
737 + }
738 +
739 + $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
740 + $legacy_page = self::$page_slug . '-instant-index';
741 + if ($page !== $legacy_page || !Metasync::current_user_has_plugin_access()) {
742 + return;
743 + }
744 +
745 + // Indexation Control is registered only when access control keeps it
746 + // visible. Redirecting to it blindly would dead-end on an
747 + // unregistered slug (wp_die "Invalid plugin page") for users whose
748 + // white-label config hides that page — the exact configs where the
749 + // removed standalone page was the feature's only UI. The bare plugin
750 + // slug is the floor: its top-level callback resolves to the first
751 + // reachable page, so it is always a safe target.
752 + if (Metasync_Access_Control::user_can_access('hide_indexation_control')) {
753 + $target = admin_url('admin.php?page=' . self::$page_slug . '-seo-controls');
754 + $query = [];
755 + foreach (['tab', 'subtab'] as $key) {
756 + if (isset($_GET[$key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
757 + $query[$key] = sanitize_key(wp_unslash($_GET[$key])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
758 + }
759 + }
760 + if (!empty($query)) {
761 + $target = add_query_arg($query, $target);
762 + }
763 + } else {
764 + $target = admin_url('admin.php?page=' . self::$page_slug);
765 + }
766 +
767 + wp_safe_redirect($target);
768 + exit;
769 + }
770 +
712 771 public function metasync_display_error_log() {
713 772 Metasync_Debug_Manager::instance()->metasync_display_error_log($this);
714 773 }
774 +
715 775 public function metasync_update_wp_config() {
716 776 Metasync_Debug_Manager::instance()->metasync_update_wp_config();
717 777 }
718 778
@@ -755,9 +815,9 @@
755 815 ]);
756 816 $_COOKIE['metasync_previous_slug'] = $new_slug;
757 817 // Redirect to the new slug
758 818 $redirect_url = admin_url('admin.php?page=' . $old_slug);
759 - wp_redirect($redirect_url);
819 + wp_safe_redirect($redirect_url);
760 820 exit;
761 821 }else{
762 822 self::$page_slug = Metasync::get_option('general')['white_label_plugin_menu_slug']=="" ? "searchatlas":Metasync::get_option('general')['white_label_plugin_menu_slug'];
763 823 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
@@ -763,9 +823,9 @@
763 823 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
764 824
765 825 #add redirection for when the old slug is not defined
766 826 #this fixes the redirect issue #
767 - wp_redirect($redirect_url);
827 + wp_safe_redirect($redirect_url);
768 828 exit;
769 829 }
770 830 }
771 831 public function metasync_view_detials_url( $plugin_meta, $plugin_file, $plugin_data ) {
@@ -776,9 +836,10 @@
776 836 if (strpos($meta, 'open-plugin-details-modal') !== false) {
777 837 $meta = sprintf(
778 838 '<a href="%s" class="thickbox open-plugin-details-modal" aria-label="%s" data-title="%s">%s</a>',
779 839 add_query_arg('TB_iframe', 'true', $plugin_uri),
780 - esc_attr(sprintf(esc_html__('More information about %s'), $plugin_data['Name'])),
840 + /* translators: %s: plugin name. */
841 + esc_attr(sprintf(esc_html__('More information about %s', 'metasync'), $plugin_data['Name'])),
781 842 esc_attr($plugin_data['Name']),
782 843 esc_html__('View details', 'metasync')
783 844 );
784 845 break; // Exit loop after replacing the link
@@ -826,53 +887,8 @@
826 887 return $all_plugins;
827 888 }
828 889
829 890 /**
830 - * Inject the whitelabel icon into the update_plugins transient so that
831 - * /wp-admin/update-core.php (Dashboard → Updates) shows the WL icon
832 - * instead of the SearchAtlas icon returned by the update API.
833 - *
834 - * @param object $transient The site transient object.
835 - * @return object
836 - */
837 - public function inject_whitelabel_icon_into_update_transient($transient) {
838 - if (empty($transient) || !is_object($transient)) {
839 - return $transient;
840 - }
841 -
842 - $general = Metasync::get_option('general');
843 - if (!is_array($general)) {
844 - return $transient;
845 - }
846 -
847 - $icon_url = $general['white_label_plugin_menu_icon'] ?? '';
848 - if (empty($icon_url)) {
849 - return $transient;
850 - }
851 -
852 - $this_plugin = plugin_basename(dirname(__DIR__) . '/metasync.php');
853 -
854 - // Inject into pending updates list
855 - if (!empty($transient->response) && isset($transient->response[$this_plugin])) {
856 - $transient->response[$this_plugin]->icons = [
857 - '1x' => $icon_url,
858 - '2x' => $icon_url,
859 - ];
860 - }
861 -
862 - // Also inject into the "no update needed" list so the icon appears
863 - // on the updates screen even when the plugin is up-to-date
864 - if (!empty($transient->no_update) && isset($transient->no_update[$this_plugin])) {
865 - $transient->no_update[$this_plugin]->icons = [
866 - '1x' => $icon_url,
867 - '2x' => $icon_url,
868 - ];
869 - }
870 -
871 - return $transient;
872 - }
873 -
874 - /**
875 891 * Register the stylesheets for the admin area.
876 892 *
877 893 * @since 1.0.0
878 894 */
@@ -877,9 +893,9 @@
877 893 * @since 1.0.0
878 894 */
879 895 public function enqueue_styles()
880 896 {
881 - $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
897 + $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
882 898
883 899 if (strpos($current_page, self::$page_slug) === 0) {
884 900 wp_enqueue_style(
885 901 $this->plugin_name,
@@ -907,10 +923,11 @@
907 923 'all'
908 924 );
909 925 }
910 926
911 - // Enqueue wizard CSS if on wizard page
912 - if (isset($_GET['page']) && strpos($_GET['page'], '-setup-wizard') !== false) {
927 + // Enqueue wizard CSS on all MetaSync pages because the reusable consent
928 + // card is also displayed inside General Settings.
929 + if (strpos($current_page, self::$page_slug) === 0 || (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
913 930 wp_enqueue_style(
914 931 $this->plugin_name . '-setup-wizard',
915 932 plugin_dir_url(__FILE__) . 'css/metasync-setup-wizard.css',
916 933 array($this->plugin_name . '-dashboard'),
@@ -919,9 +936,9 @@
919 936 );
920 937 }
921 938
922 939 // Enqueue SEO Health CSS if on the SEO Health page
923 - if (isset($_GET['page']) && strpos($_GET['page'], '-seo-health') !== false) {
940 + 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
924 941 wp_enqueue_style(
925 942 $this->plugin_name . '-seo-health',
926 943 plugin_dir_url(__FILE__) . 'css/metasync-seo-health.css',
927 944 array($this->plugin_name . '-dashboard'),
@@ -938,9 +955,9 @@
938 955 */
939 956 public function enqueue_scripts()
940 957 {
941 958 // --- Phase 5 (#887): Extracted inline JS files ---
942 - $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
959 + $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
943 960 $plugin_root_url = plugin_dir_url(dirname(__FILE__));
944 961 $is_metasync_page = ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0);
945 962
946 963 if ($is_metasync_page) {
@@ -1001,8 +1018,16 @@
1001 1018 array(),
1002 1019 $this->version,
1003 1020 true
1004 1021 );
1022 + // Selector navigation is a GET, so the per-page nonce rides along
1023 + // as a query parameter and gates the user_meta persist in
1024 + // Metasync_Per_Page_Helper::resolve().
1025 + wp_localize_script(
1026 + $this->plugin_name . '-per-page',
1027 + 'metasyncPerPage',
1028 + array('nonces' => Metasync_Per_Page_Helper::page_nonces())
1029 + );
1005 1030 }
1006 1031
1007 1032 // Dashboard iframe height (only on dashboard page)
1008 1033 if ($current_page === self::$page_slug . '-dashboard' || $current_page === self::$page_slug) {
@@ -1053,12 +1078,12 @@
1053 1078 true
1054 1079 );
1055 1080 // Pass the active tab from server (handles POST redirect)
1056 1081 $sitemap_active_tab = 'general';
1057 - if (isset($_GET['tab']) && in_array($_GET['tab'], ['general', 'news', 'video'], true)) {
1058 - $sitemap_active_tab = sanitize_text_field(wp_unslash($_GET['tab']));
1059 - } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) {
1060 - $sitemap_active_tab = sanitize_text_field(wp_unslash($_POST['redirect_tab']));
1082 + 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
1083 + $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
1084 + } 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
1085 + $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
1061 1086 }
1062 1087 wp_localize_script($this->plugin_name . '-sitemap-tabs', 'metasyncSitemapTabs', [
1063 1088 'activeTab' => $sitemap_active_tab,
1064 1089 ]);
@@ -1386,8 +1411,10 @@
1386 1411 'nonce' => wp_create_nonce('metasync_nonce'),
1387 1412 'sa_connect_nonce' => $sa_connect_nonce,
1388 1413 'reset_auth_nonce' => wp_create_nonce('metasync_reset_auth_nonce'),
1389 1414 'burst_ping_nonce' => wp_create_nonce('metasync_burst_ping'),
1415 + 'clear_otto_cache_nonce' => wp_create_nonce('metasync_clear_otto_cache'),
1416 + 'lglogin_nonce' => wp_create_nonce('metasync_lglogin'),
1390 1417 'heartbeat_state' => $heartbeat_state,
1391 1418 'dashboard_domain' => self::get_effective_dashboard_domain(),
1392 1419 'support_email' => Metasync::SUPPORT_EMAIL,
1393 1420 'documentation_domain' => Metasync::DOCUMENTATION_DOMAIN,
@@ -1451,9 +1478,9 @@
1451 1478 add_action('admin_notices', array($this, 'display_page_builder_notice'));
1452 1479 // Display update warning banner if plugin update is available
1453 1480 add_action('admin_notices', array($this, 'display_update_warning_banner'));
1454 1481 // Enqueue wizard assets if on wizard page
1455 - if (isset($_GET['page']) && strpos($_GET['page'], '-setup-wizard') !== false) {
1482 + 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
1456 1483 wp_enqueue_script(
1457 1484 $this->plugin_name . '-setup-wizard',
1458 1485 plugin_dir_url(__FILE__) . 'js/metasync-setup-wizard.js',
1459 1486 array('jquery'),
@@ -1652,13 +1679,8 @@
1652 1679 {
1653 1680 Metasync_Connect_Manager::instance()->test_searchatlas_ajax_endpoint();
1654 1681 }
1655 1682
1656 - public function simple_ajax_test()
1657 - {
1658 - Metasync_Connect_Manager::instance()->simple_ajax_test();
1659 - }
1660 -
1661 1683 /**
1662 1684 * Create Admin Dashboard Iframe Page
1663 1685 * Embeds the Search Atlas dashboard directly in WordPress admin
1664 1686 */
@@ -1745,9 +1767,9 @@
1745 1767 } else {
1746 1768 echo '<p style="color: var(--dashboard-text-secondary);">ℹ️ No cache plugins detected.</p>';
1747 1769 }
1748 1770 } catch (Exception $e) {
1749 - error_log('MetaSync Cache Status Error: ' . $e->getMessage());
1771 + error_log('MetaSync Cache Status Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1750 1772 echo '<p style="color: var(--dashboard-error);">⚠️ An error occurred while retrieving cache plugin status.</p>';
1751 1773 }
1752 1774 } else {
1753 1775 echo '<p style="color: var(--dashboard-error);">⚠️ Cache Purge class not loaded.</p>';
@@ -1764,12 +1786,12 @@
1764 1786 </form>
1765 1787
1766 1788 <?php
1767 1789 // Display success/error messages
1768 - if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') {
1769 - $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0;
1770 - $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0;
1771 - $plugins = isset($_GET['plugins']) ? sanitize_text_field(wp_unslash($_GET['plugins'])) : '';
1790 + if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1791 + $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1792 + $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1793 + $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
1772 1794
1773 1795 if ($cleared > 0) {
1774 1796 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1775 1797 echo '✅ <strong>Success!</strong> Cleared cache for ' . intval( $cleared ) . ' plugin(s)';
@@ -1789,10 +1811,10 @@
1789 1811 echo '</p></div>';
1790 1812 }
1791 1813 }
1792 1814
1793 - if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') {
1794 - $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : '';
1815 + if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1816 + $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
1795 1817 if (empty($message)) {
1796 1818 $message = 'An unknown error occurred while clearing cache. Please check error logs for details.';
1797 1819 }
1798 1820 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
@@ -1889,12 +1911,13 @@
1889 1911 </form>
1890 1912
1891 1913 <?php
1892 1914 // Hosting cache result messages
1893 - if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') {
1894 - $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode($_GET['hc_cleared'])) : '';
1895 - $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode($_GET['hc_failed'])) : '';
1896 - $hc_not_detected = isset($_GET['hc_not_detected']) ? sanitize_text_field(urldecode($_GET['hc_not_detected'])) : '';
1915 + 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
1916 + // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each value is unslashed and sanitized on the following assignments.
1917 + $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
1918 + $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
1919 + $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
1897 1920
1898 1921 if ($hc_cleared) {
1899 1922 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1900 1923 echo '✅ <strong>Success!</strong> Purged hosting cache on: ' . esc_html($hc_cleared);
@@ -1909,8 +1932,9 @@
1909 1932 echo '<div class="notice notice-info inline" style="margin-top: 10px;"><p>';
1910 1933 echo 'ℹ️ No enabled hosting providers were detected on this server (' . esc_html($hc_not_detected) . ').';
1911 1934 echo '</p></div>';
1912 1935 }
1936 + // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1913 1937 }
1914 1938 ?>
1915 1939
1916 1940 <script>
@@ -2075,11 +2099,11 @@
2075 2099 </div>
2076 2100
2077 2101 <?php
2078 2102 // Display success/error messages
2079 - if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') {
2080 - $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0;
2081 - $url = isset($_GET['url']) ? urldecode(sanitize_text_field(wp_unslash($_GET['url']))) : '';
2103 + 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
2104 + $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
2105 + $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
2082 2106
2083 2107 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
2084 2108 if (!empty($url)) {
2085 2109 echo '✅ <strong>Success!</strong> Cleared cache for URL: <code>' . esc_html($url) . '</code> (' . intval( $cleared_count ) . ' entries)';
@@ -2088,10 +2112,10 @@
2088 2112 }
2089 2113 echo '</p></div>';
2090 2114 }
2091 2115
2092 - if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') {
2093 - $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : 'An unknown error occurred.';
2116 + 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
2117 + $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
2094 2118 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
2095 2119 echo '❌ <strong>Error:</strong> ' . esc_html($message);
2096 2120 echo '</p></div>';
2097 2121 }
@@ -2130,9 +2154,9 @@
2130 2154 <td>
2131 2155 <input type="url"
2132 2156 id="otto_cache_url"
2133 2157 name="otto_cache_url"
2134 - value="<?php echo isset($_GET['url']) ? esc_attr(urldecode(sanitize_text_field(wp_unslash($_GET['url'])))) : ''; ?>"
2158 + 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 ?>"
2135 2159 class="regular-text"
2136 2160 placeholder="https://example.com/page/"
2137 2161 required />
2138 2162 <p class="description" style="color: var(--dashboard-text-secondary);">Enter the full URL of the page whose cache you want to clear.</p>
@@ -2203,9 +2227,10 @@
2203 2227 /**
2204 2228 * AJAX handler for saving hosting cache settings
2205 2229 */
2206 2230 public function ajax_save_hosting_cache_settings() {
2207 - if (!isset($_POST['hosting_cache_nonce']) || !wp_verify_nonce($_POST['hosting_cache_nonce'], 'metasync_hosting_cache_nonce')) {
2231 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2232 + if (!isset($_POST['hosting_cache_nonce']) || !wp_verify_nonce(wp_unslash($_POST['hosting_cache_nonce']), 'metasync_hosting_cache_nonce')) {
2208 2233 wp_send_json_error(array('message' => 'Security check failed'));
2209 2234 return;
2210 2235 }
2211 2236
@@ -2226,9 +2251,10 @@
2226 2251 /**
2227 2252 * AJAX handler: save object cache behaviour settings
2228 2253 */
2229 2254 public function ajax_save_object_cache_settings() {
2230 - if (!isset($_POST['object_cache_nonce']) || !wp_verify_nonce($_POST['object_cache_nonce'], 'metasync_object_cache_nonce')) {
2255 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2256 + if (!isset($_POST['object_cache_nonce']) || !wp_verify_nonce(wp_unslash($_POST['object_cache_nonce']), 'metasync_object_cache_nonce')) {
2231 2257 wp_send_json_error(array('message' => 'Security check failed'));
2232 2258 return;
2233 2259 }
2234 2260
@@ -2255,9 +2281,10 @@
2255 2281 /**
2256 2282 * AJAX handler for saving OTTO Cache TTL
2257 2283 */
2258 2284 public function ajax_save_otto_cache_ttl() {
2259 - if (!isset($_POST['otto_cache_ttl_nonce']) || !wp_verify_nonce($_POST['otto_cache_ttl_nonce'], 'metasync_otto_cache_ttl_nonce')) {
2285 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2286 + if (!isset($_POST['otto_cache_ttl_nonce']) || !wp_verify_nonce(wp_unslash($_POST['otto_cache_ttl_nonce']), 'metasync_otto_cache_ttl_nonce')) {
2260 2287 wp_send_json_error(array('message' => 'Security check failed.'));
2261 2288 return;
2262 2289 }
2263 2290
@@ -2283,9 +2310,10 @@
2283 2310 /**
2284 2311 * admin_post handler: purge WP Engine and Kinsta hosting-level caches
2285 2312 */
2286 2313 public function handle_purge_hosting_cache() {
2287 - if (!isset($_POST['hosting_cache_purge_nonce']) || !wp_verify_nonce($_POST['hosting_cache_purge_nonce'], 'metasync_hosting_cache_purge_nonce')) {
2314 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2315 + if (!isset($_POST['hosting_cache_purge_nonce']) || !wp_verify_nonce(wp_unslash($_POST['hosting_cache_purge_nonce']), 'metasync_hosting_cache_purge_nonce')) {
2288 2316 wp_die('Security check failed');
2289 2317 }
2290 2318
2291 2319 if (!Metasync::current_user_has_plugin_access()) {
@@ -2305,9 +2333,9 @@
2305 2333 WpeCommon::purge_varnish_cache();
2306 2334 WpeCommon::purge_memcached();
2307 2335 $cleared[] = 'WP Engine';
2308 2336 } catch (Exception $e) {
2309 - error_log('MetaSync: WP Engine hosting cache purge failed - ' . $e->getMessage());
2337 + 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
2310 2338 $failed[] = 'WP Engine';
2311 2339 }
2312 2340 } else {
2313 2341 $not_detected[] = 'WP Engine';
@@ -2320,9 +2348,9 @@
2320 2348 try {
2321 2349 KinstaCache::get_instance()->kinsta_cache_purge_full();
2322 2350 $cleared[] = 'Kinsta';
2323 2351 } catch (Exception $e) {
2324 - error_log('MetaSync: Kinsta hosting cache purge failed - ' . $e->getMessage());
2352 + 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
2325 2353 $failed[] = 'Kinsta';
2326 2354 }
2327 2355 } else {
2328 2356 $not_detected[] = 'Kinsta';
@@ -2717,9 +2745,9 @@
2717 2745 # Initialize MetaSync API request class and trigger synchronization
2718 2746 (new Metasync_Sync_Requests())->SyncCustomerParams();
2719 2747 } catch (Exception $e) {
2720 2748 # Log any API request errors for debugging
2721 - error_log('Metasync API Error: ' . $e->getMessage());
2749 + error_log('Metasync API Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2722 2750 }
2723 2751 }
2724 2752
2725 2753 /*
@@ -2734,9 +2762,9 @@
2734 2762 # Initialize MetaSync API request class and trigger synchronization
2735 2763 (new Metasync_Sync_Requests())->SyncCustomerParams();
2736 2764 } catch (Exception $e) {
2737 2765 # Log any API request errors for debugging
2738 - error_log('Metasync API Error: ' . $e->getMessage());
2766 + error_log('Metasync API Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2739 2767 }
2740 2768 }
2741 2769
2742 2770 /**
@@ -2773,9 +2801,9 @@
2773 2801 // Settings shape the whole site's conversion pipeline — restrict
2774 2802 // the save (and the reset) to site admins, not just anyone who can
2775 2803 // open the page via plugin_access_roles.
2776 2804 if (!current_user_can('manage_options')) {
2777 - wp_die(__('Sorry, you are not allowed to manage media optimization settings.', 'metasync'));
2805 + wp_die(esc_html__('Sorry, you are not allowed to manage media optimization settings.', 'metasync'));
2778 2806 }
2779 2807
2780 2808 // Handle reset to defaults
2781 2809 if (!empty($_POST['metasync_media_reset'])) {
@@ -2782,8 +2810,9 @@
2782 2810 $defaults = Metasync_Media_Settings::get_defaults();
2783 2811 Metasync_Media_Settings::save_settings($defaults);
2784 2812 $save_success = true;
2785 2813 } elseif (isset($_POST['metasync_media'])) {
2814 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Media_Settings::save_settings().
2786 2815 $input = wp_unslash($_POST['metasync_media']);
2787 2816 Metasync_Media_Settings::save_settings($input);
2788 2817 $save_success = true;
2789 2818 }
@@ -2815,8 +2844,15 @@
2815 2844 * Code Minification page callback
2816 2845 */
2817 2846 public function create_admin_code_minification_page()
2818 2847 {
2848 + // Settings shape the whole site's asset pipeline — restrict
2849 + // the save (and the reset) to site admins, not just anyone who can
2850 + // open the page via plugin_access_roles.
2851 + if (!empty($_POST) && !current_user_can('manage_options')) {
2852 + wp_die(esc_html__('Sorry, you are not allowed to manage code minification settings.', 'metasync'));
2853 + }
2854 +
2819 2855 $this->render_layout_open('Code Minification', 'code_minification', 'Minify CSS, JavaScript, and HTML to improve performance.');
2820 2856 // Load settings and compatibility classes
2821 2857 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-settings.php';
2822 2858 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-cache.php';
@@ -2833,8 +2869,9 @@
2833 2869 $defaults = Metasync_Minification_Settings::get_defaults();
2834 2870 Metasync_Minification_Settings::save_settings($defaults);
2835 2871 $save_success = true;
2836 2872 } elseif (isset($_POST['metasync_code_min'])) {
2873 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Minification_Settings::save_settings().
2837 2874 $input = (array) wp_unslash($_POST['metasync_code_min']);
2838 2875 Metasync_Minification_Settings::save_settings($input);
2839 2876 $save_success = true;
2840 2877 }
@@ -2889,8 +2926,9 @@
2889 2926 $max_bytes = 10 * 1024 * 1024;
2890 2927 if (filesize($file) > $max_bytes) {
2891 2928 $size_mb = round(filesize($file) / 1024 / 1024, 1);
2892 2929 wp_send_json_error(sprintf(
2930 + /* translators: %s: file size in megabytes. */
2893 2931 __('Optimization skipped: file size (%s MB) exceeds the 10 MB safety limit to prevent memory issues.', 'metasync'),
2894 2932 $size_mb
2895 2933 ));
2896 2934 }
@@ -3099,8 +3137,9 @@
3099 3137 if (!Metasync_Image_Converter::can_revert($id)) {
3100 3138 $skipped++;
3101 3139 $file = get_attached_file($id);
3102 3140 $name = $file ? basename($file) : "ID {$id}";
3141 + /* translators: %s: attachment file name. */
3103 3142 $errors[] = sprintf(__('%s: skipped — original image unavailable.', 'metasync'), $name);
3104 3143 continue;
3105 3144 }
3106 3145
@@ -3109,8 +3148,9 @@
3109 3148 } else {
3110 3149 $failed++;
3111 3150 $file = get_attached_file($id);
3112 3151 $name = $file ? basename($file) : "ID {$id}";
3152 + /* translators: %s: attachment file name. */
3113 3153 $errors[] = sprintf(__('%s: revert failed (original file may not exist).', 'metasync'), $name);
3114 3154 }
3115 3155 }
3116 3156
@@ -3195,8 +3235,86 @@
3195 3235 Metasync_Media_Batch_Optimizer::process_batch_tick();
3196 3236 }
3197 3237
3198 3238 /**
3239 + * AJAX: Start SEO Restore batch.
3240 + */
3241 + public function ajax_seo_restore_start()
3242 + {
3243 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3244 +
3245 + if (!current_user_can('manage_options')) {
3246 + wp_send_json_error(__('Permission denied.', 'metasync'));
3247 + }
3248 +
3249 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3250 +
3251 + $progress = Metasync_Seo_Restore::start_batch();
3252 + wp_send_json_success($progress);
3253 + }
3254 +
3255 + /**
3256 + * AJAX: Cancel SEO Restore batch.
3257 + */
3258 + public function ajax_seo_restore_cancel()
3259 + {
3260 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3261 +
3262 + if (!current_user_can('manage_options')) {
3263 + wp_send_json_error(__('Permission denied.', 'metasync'));
3264 + }
3265 +
3266 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3267 +
3268 + Metasync_Seo_Restore::cancel_batch();
3269 + wp_send_json_success();
3270 + }
3271 +
3272 + /**
3273 + * AJAX: Get SEO Restore batch progress.
3274 + */
3275 + public function ajax_seo_restore_progress()
3276 + {
3277 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3278 +
3279 + if (!current_user_can('manage_options')) {
3280 + wp_send_json_error(__('Permission denied.', 'metasync'));
3281 + }
3282 +
3283 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3284 +
3285 + $progress = Metasync_Seo_Restore::get_progress();
3286 + wp_send_json_success($progress);
3287 + }
3288 +
3289 + /**
3290 + * AJAX: Process one SEO Restore tick (browser-driven chaining).
3291 + */
3292 + public function ajax_seo_restore_process_tick()
3293 + {
3294 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3295 +
3296 + if (!current_user_can('manage_options')) {
3297 + wp_send_json_error(__('Permission denied.', 'metasync'));
3298 + }
3299 +
3300 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3301 +
3302 + $progress = Metasync_Seo_Restore::process_ajax_tick();
3303 + wp_send_json_success($progress);
3304 + }
3305 +
3306 + /**
3307 + * Cron handler: Process SEO Restore batch tick.
3308 + */
3309 + public function handle_seo_restore_cron()
3310 + {
3311 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3312 +
3313 + Metasync_Seo_Restore::process_batch_tick();
3314 + }
3315 +
3316 + /**
3199 3317 * Report Issue page callback
3200 3318 */
3201 3319 public function create_admin_report_issue_page()
3202 3320 {
@@ -3208,8 +3326,17 @@
3208 3326 * XML Sitemap page callback
3209 3327 */
3210 3328 public function create_admin_xml_sitemap_page()
3211 3329 {
3330 + // Every form action below mutates site-wide state — sitemap options,
3331 + // sitemap files on disk, robots.txt, other plugins' sitemap generators
3332 + // — so they are restricted to site admins, not just anyone who can
3333 + // open the page via plugin_access_roles. The nonce is CSRF
3334 + // protection, not authorization.
3335 + if (!empty($_POST) && !current_user_can('manage_options')) {
3336 + wp_die(esc_html__('Sorry, you are not allowed to manage sitemap settings.', 'metasync'));
3337 + }
3338 +
3212 3339 // Load sitemap generator class
3213 3340 require_once plugin_dir_path(dirname(__FILE__)) . 'sitemap/class-metasync-sitemap-generator.php';
3214 3341
3215 3342 $sitemap_generator = new Metasync_Sitemap_Generator();
@@ -3237,11 +3364,12 @@
3237 3364
3238 3365 update_option('metasync_sitemap_settings', $sitemap_settings);
3239 3366
3240 3367 // Regenerate sitemap with new content settings
3241 - $result = $sitemap_generator->generate_sitemap();
3368 + $result = $sitemap_generator->generate_sitemap( true );
3242 3369 if (is_wp_error($result)) {
3243 3370 echo '<div class="notice notice-error"><p>' . esc_html(
3371 + /* translators: %s: error message. */
3244 3372 sprintf(__('Settings saved but sitemap generation failed: %s', 'metasync'), $result->get_error_message())
3245 3373 ) . '</p></div>';
3246 3374 } elseif (false === $result) {
3247 3375 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>';
@@ -3256,10 +3384,10 @@
3256 3384
3257 3385 // Build generic taxonomy filters
3258 3386 $news_taxonomies = [];
3259 3387 if (!empty($_POST['news_taxonomies']) && is_array($_POST['news_taxonomies'])) {
3260 - foreach ($_POST['news_taxonomies'] as $tax_name => $term_ids) {
3261 - $news_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) $term_ids);
3388 + foreach (wp_unslash($_POST['news_taxonomies']) as $tax_name => $term_ids) {
3389 + $news_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) wp_unslash($term_ids));
3262 3390 }
3263 3391 }
3264 3392
3265 3393 $news_settings = [
@@ -3284,9 +3412,9 @@
3284 3412 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3285 3413 } else {
3286 3414 // Also remove physical file if it exists
3287 3415 if (file_exists(ABSPATH . 'news-sitemap.xml')) {
3288 - @unlink(ABSPATH . 'news-sitemap.xml');
3416 + @wp_delete_file(ABSPATH . 'news-sitemap.xml');
3289 3417 }
3290 3418 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
3291 3419 }
3292 3420 }
@@ -3297,10 +3425,10 @@
3297 3425
3298 3426 // Build generic taxonomy filters
3299 3427 $video_taxonomies = [];
3300 3428 if (!empty($_POST['video_taxonomies']) && is_array($_POST['video_taxonomies'])) {
3301 - foreach ($_POST['video_taxonomies'] as $tax_name => $term_ids) {
3302 - $video_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) $term_ids);
3429 + foreach (wp_unslash($_POST['video_taxonomies']) as $tax_name => $term_ids) {
3430 + $video_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) wp_unslash($term_ids));
3303 3431 }
3304 3432 }
3305 3433
3306 3434 $video_settings = [
@@ -3308,8 +3436,12 @@
3308 3436 'post_types' => array_map('sanitize_key', (array) ($_POST['video_post_types'] ?? ['post', 'page'])),
3309 3437 'auto_detect' => isset($_POST['auto_detect']),
3310 3438 'taxonomies' => $video_taxonomies,
3311 3439 'excluded_urls' => sanitize_textarea_field(wp_unslash($_POST['video_excluded_urls'] ?? '')),
3440 + // This array replaces the stored option wholesale, so every
3441 + // setting must be represented here or it is wiped on save.
3442 + 'video_url_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_url_meta_keys'] ?? '')),
3443 + 'video_thumbnail_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_thumbnail_meta_keys'] ?? '')),
3312 3444 ];
3313 3445
3314 3446 // Always invalidate old cache before saving new settings
3315 3447 delete_transient('metasync_vsm_' . md5('video-sitemap.xml'));
@@ -3322,9 +3454,9 @@
3322 3454 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3323 3455 } else {
3324 3456 // Also remove physical file if it exists
3325 3457 if (file_exists(ABSPATH . 'video-sitemap.xml')) {
3326 - @unlink(ABSPATH . 'video-sitemap.xml');
3458 + @wp_delete_file(ABSPATH . 'video-sitemap.xml');
3327 3459 }
3328 3460 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
3329 3461 }
3330 3462 }
@@ -3388,14 +3520,15 @@
3388 3520 gc_collect_cycles();
3389 3521 }
3390 3522
3391 3523 // Generate main sitemap (its index will include news/video since they now exist)
3392 - $result = $sitemap_generator->generate_sitemap();
3524 + $result = $sitemap_generator->generate_sitemap( true );
3393 3525
3394 3526 if (is_wp_error($result)) {
3395 3527 $error_msg = $result->get_error_message();
3396 - error_log('[MetaSync] Sitemap generation failed: ' . $error_msg);
3528 + error_log('[MetaSync] Sitemap generation failed: ' . $error_msg); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3397 3529 echo '<div class="notice notice-error"><p>' . esc_html(
3530 + /* translators: %s: error message. */
3398 3531 sprintf(__('Sitemap generation failed: %s', 'metasync'), $error_msg)
3399 3532 ) . '</p></div>';
3400 3533 } elseif (false === $result) {
3401 3534 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>';
@@ -3402,8 +3535,9 @@
3402 3535 } else {
3403 3536 $message = esc_html__('Sitemap generated successfully!', 'metasync');
3404 3537 if (!empty($extras)) {
3405 3538 $message .= ' ' . sprintf(
3539 + /* translators: %s: list of sitemap types. */
3406 3540 esc_html__('Also generated %s sitemap(s).', 'metasync'),
3407 3541 implode(' & ', $extras)
3408 3542 );
3409 3543 }
@@ -3592,48 +3726,8 @@
3592 3726 Metasync_Admin_Pages::get_instance($this)->create_admin_breadcrumbs_page();
3593 3727 }
3594 3728
3595 3729 /**
3596 - * Google Instant Index Setting page callback
3597 - */
3598 - public function create_admin_google_instant_index_page()
3599 - {
3600 - $this->render_layout_open('Instant Indexing', 'instant_index', 'Submit URLs to Google for instant indexing via the Indexing API.');
3601 -
3602 - // Render shared Google Index credentials section
3603 - if (!function_exists('google_index_direct')) {
3604 - if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) {
3605 - require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
3606 - } else {
3607 - error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php');
3608 - return;
3609 - }
3610 - }
3611 - $google_index = google_index_direct();
3612 - $service_info = $google_index->get_service_account_info();
3613 - $is_configured = !isset($service_info['error']);
3614 -
3615 - $saved_json_display = $is_configured ? $google_index->get_redacted_config_json() : '';
3616 -
3617 - include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-index-api-settings.php';
3618 -
3619 - // Render post types selection with save form
3620 - $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
3621 - $post_types_settings = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
3622 - ?>
3623 - <form method="POST" action="">
3624 - <?php wp_nonce_field('metasync_instant_indexing_settings', 'metasync_instant_indexing_nonce'); ?>
3625 - <?php include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-instant-post-types.php'; ?>
3626 - <div class="dashboard-card" style="padding: 20px;">
3627 - <?php submit_button('Save Post Types', 'primary', 'submit', false, array('class' => 'button button-primary')); ?>
3628 - </div>
3629 - </form>
3630 - <?php
3631 -
3632 - $this->render_layout_close();
3633 - }
3634 -
3635 - /**
3636 3730 * Google Console page callback
3637 3731 */
3638 3732 public function create_admin_google_console_page()
3639 3733 {
@@ -3739,8 +3833,9 @@
3739 3833 if (!get_transient('metasync_llms_conflict')) {
3740 3834 return;
3741 3835 }
3742 3836 echo '<div class="notice notice-info is-dismissible"><p>';
3837 + /* translators: %s: effective plugin name (whitelabel-aware). */
3743 3838 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()));
3744 3839 echo '</p></div>';
3745 3840 }
3746 3841
@@ -3848,9 +3943,9 @@
3848 3943 {
3849 3944 register_rest_route('metasync/v1', '/ping', array(
3850 3945 'methods' => array('GET', 'POST'),
3851 3946 'callback' => array($this, 'handle_ping_rest_endpoint'),
3852 - 'permission_callback' => '__return_true', // Allow public access
3947 + 'permission_callback' => '__return_true',
3853 3948 'args' => array(
3854 3949 'test' => array(
3855 3950 'description' => 'Optional test parameter',
3856 3951 'type' => 'string',
@@ -3932,9 +4027,9 @@
3932 4027 return;
3933 4028 }
3934 4029
3935 4030 // Get pagination parameters
3936 - $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
4031 + $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
3937 4032 $per_page = Metasync_Per_Page_Helper::resolve('sync_log', 10);
3938 4033 $offset = ($page - 1) * $per_page;
3939 4034
3940 4035 // Get filters
@@ -3939,10 +4034,10 @@
3939 4034
3940 4035 // Get filters
3941 4036 $filters = [
3942 4037 // UI exposes date_range and status only. We compute date_from/date_to based on date_range
3943 - 'date_range' => isset($_GET['date_range']) ? sanitize_text_field(wp_unslash($_GET['date_range'])) : '',
3944 - 'status' => isset($_GET['status']) ? sanitize_text_field(wp_unslash($_GET['status'])) : '',
4038 + '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
4039 + '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
3945 4040 ];
3946 4041
3947 4042 // Map date_range to concrete date_from/date_to for DB queries
3948 4043 $date_range = $filters['date_range'];
@@ -3951,28 +4046,28 @@
3951 4046 $date_to = '';
3952 4047
3953 4048 if (!empty($date_range)) {
3954 4049 // End boundary is now by default
3955 - $date_to = date('Y-m-d H:i:s', $wp_now_ts);
4050 + $date_to = gmdate('Y-m-d H:i:s', $wp_now_ts);
3956 4051
3957 4052 if ($date_range === 'today') {
3958 4053 $start_ts = strtotime('today', $wp_now_ts);
3959 - $date_from = date('Y-m-d H:i:s', $start_ts);
4054 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3960 4055 } elseif ($date_range === 'yesterday') {
3961 4056 $start_ts = strtotime('yesterday', $wp_now_ts);
3962 4057 $end_ts = strtotime('today', $wp_now_ts) - 1; // end of yesterday
3963 - $date_from = date('Y-m-d H:i:s', $start_ts);
3964 - $date_to = date('Y-m-d H:i:s', $end_ts);
4058 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
4059 + $date_to = gmdate('Y-m-d H:i:s', $end_ts);
3965 4060 } elseif ($date_range === 'this_week') {
3966 4061 $start_of_week = (int) get_option('start_of_week', 1); // 0=Sun, 1=Mon
3967 - $day_of_week = (int) date('w', $wp_now_ts); // 0=Sun..6=Sat
4062 + $day_of_week = (int) gmdate('w', $wp_now_ts); // 0=Sun..6=Sat
3968 4063 // Convert start_of_week to PHP's 0..6 where 0=Sunday
3969 4064 $delta_days = ($day_of_week - $start_of_week + 7) % 7;
3970 4065 $start_ts = strtotime('-' . $delta_days . ' days', strtotime('today', $wp_now_ts));
3971 - $date_from = date('Y-m-d H:i:s', $start_ts);
4066 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3972 4067 } elseif ($date_range === 'this_month') {
3973 - $start_ts = strtotime(date('Y-m-01 00:00:00', $wp_now_ts));
3974 - $date_from = date('Y-m-d H:i:s', $start_ts);
4068 + $start_ts = strtotime(gmdate('Y-m-01 00:00:00', $wp_now_ts));
4069 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3975 4070 } elseif ($date_range === 'all') {
3976 4071 // no bounds
3977 4072 }
3978 4073 }
@@ -3999,12 +4094,24 @@
3999 4094 ?>
4000 4095 <div class="dashboard-card">
4001 4096 <div class="sync-log-header">
4002 4097 <div class="sync-log-title-section">
4003 - <h2>Changes Log</h2>
4004 - <p style="color: var(--dashboard-text-secondary); margin-bottom: 0;">
4005 - Recent content synchronizations from external tools.
4006 - <span style="margin-left:8px; font-size:12px; opacity:.75;">Records are automatically removed after 90 days.</span>
4098 + <?php
4099 + // The page title and description are already rendered by
4100 + // render_layout_open() above. Repeating them here printed
4101 + // "Changes Log" twice on screen, so the card keeps only the
4102 + // retention hint — the one piece of copy the layout header
4103 + // does not carry.
4104 + ?>
4105 + <p style="color: var(--dashboard-text-secondary); margin-top: 0; margin-bottom: 0;">
4106 + <?php
4107 + // No opacity here: this used to be a de-emphasised
4108 + // aside trailing a full-contrast sentence, but it is
4109 + // now the card's only copy. At 12px/.75 it composites
4110 + // to 4.28:1 on the card background, under the 4.5:1
4111 + // AA floor; at full strength it reads 6.52:1.
4112 + ?>
4113 + <span style="font-size:13px;">Records are automatically removed after 90 days.</span>
4007 4114 </p>
4008 4115 </div>
4009 4116
4010 4117 <!-- Filters + Clear Log button - Right aligned -->
@@ -4014,9 +4121,9 @@
4014 4121 data-nonce="<?php echo esc_attr(wp_create_nonce('metasync_clear_sync_log')); ?>">
4015 4122 🗑 Clear Log
4016 4123 </button>
4017 4124 <form method="get" class="sync-filters-form" onchange="this.submit()" style="display:flex;flex-direction:row;align-items:center;gap:12px;flex-wrap:nowrap;">
4018 - <input type="hidden" name="page" value="<?php echo esc_attr($_GET['page']); ?>">
4125 + <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 ?>">
4019 4126
4020 4127 <select name="date_range" class="sync-filter-select">
4021 4128 <option value="all" <?php selected($filters['date_range'] ?? 'all', 'all'); ?>> All Time</option>
4022 4129 <option value="today" <?php selected($filters['date_range'] ?? '', 'today'); ?>>Today</option>
@@ -4038,9 +4145,9 @@
4038 4145 <div class="sync-log-list">
4039 4146 <?php if (empty($sync_records)): ?>
4040 4147 <div class="sync-log-empty">
4041 4148 <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>
4042 - <h3>No sync records found</h3>
4149 + <h2>No sync records found</h2>
4043 4150 <p>Sync records will appear here when content/pages receive new updates.</p>
4044 4151 </div>
4045 4152 <?php else: ?>
4046 4153 <?php foreach ($sync_records as $record): ?>
@@ -4097,24 +4204,25 @@
4097 4204 <!-- Pagination -->
4098 4205 <div class="sync-log-pagination">
4099 4206 <div class="sync-log-pagination-info">
4100 4207 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; ?>
4101 - <?php echo Metasync_Per_Page_Helper::render_selector('sync_log', $per_page); ?>
4208 + <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper returns a complete escaped selector element.
4209 + echo Metasync_Per_Page_Helper::render_selector('sync_log', absint($per_page)); ?>
4102 4210 </div>
4103 4211
4104 4212 <?php if ($total_pages > 1): ?>
4105 4213 <div class="sync-log-pagination-controls">
4106 4214 <?php if ($page > 1): ?>
4107 - <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>
4215 + <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>
4108 4216 <?php endif; ?>
4109 4217
4110 4218 <?php for ($i = max(1, $page - 2); $i <= min($total_pages, $page + 2); $i++): ?>
4111 - <a href="?page=<?php echo esc_attr($_GET['page']); ?>&paged=<?php echo intval( $i ); ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>"
4219 + <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) ); ?>"
4112 4220 class="sync-pagination-btn <?php echo $i === $page ? 'active' : ''; ?>"><?php echo intval( $i ); ?></a>
4113 4221 <?php endfor; ?>
4114 4222
4115 4223 <?php if ($page < $total_pages): ?>
4116 - <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>
4224 + <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>
4117 4225 <?php endif; ?>
4118 4226 </div>
4119 4227 <?php endif; ?>
4120 4228 </div>
@@ -4203,10 +4311,10 @@
4203 4311
4204 4312 // Preserve the user-selected results-per-page value across page
4205 4313 // navigation so a non-default page size survives clicking a page link.
4206 4314 $per_page_key = Metasync_Per_Page_Helper::request_key('sync_log');
4207 - if (isset($_GET[$per_page_key])) {
4208 - $per_page = (int) $_GET[$per_page_key];
4315 + if (isset($_GET[$per_page_key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4316 + $per_page = (int) $_GET[$per_page_key]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4209 4317 if (in_array($per_page, Metasync_Per_Page_Helper::allowed_values(), true)) {
4210 4318 $query_parts[] = $per_page_key . '=' . $per_page;
4211 4319 }
4212 4320 }
@@ -4385,18 +4493,8 @@
4385 4493 {
4386 4494 Metasync_Connect_Manager::instance()->handle_session_management_early();
4387 4495 }
4388 4496
4389 - /**
4390 - * @deprecated 2.5.12 Use Metasync_Auth_Manager instead of sessions for authentication
4391 - */
4392 - private function safe_session_start() {
4393 - // This method is deprecated and no longer used
4394 - // Authentication now uses Metasync_Auth_Manager with WordPress transients and user meta
4395 - _deprecated_function(__METHOD__, '2.5.12', 'Metasync_Auth_Manager');
4396 - return Metasync_Session_Helper::safe_start();
4397 - }
4398 -
4399 4497 private function handle_whitelabel_session_logic()
4400 4498 {
4401 4499 Metasync_Connect_Manager::instance()->handle_whitelabel_session_logic();
4402 4500 }
@@ -4492,9 +4590,9 @@
4492 4590 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4493 4591 Effective Threshold
4494 4592 </label>
4495 4593 <div style="padding: 10px 12px; background: var(--dashboard-input-bg); border: 1px solid var(--dashboard-border); border-radius: 6px; color: var(--dashboard-text-secondary);">
4496 - <strong id="effective_threshold_value"><?php echo round($effective_threshold, 2); ?></strong>
4594 + <strong id="effective_threshold_value"><?php echo esc_html((string) round($effective_threshold, 2)); ?></strong>
4497 4595 </div>
4498 4596 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
4499 4597 Calculated as: cores × per-core threshold
4500 4598 </p>
@@ -4512,13 +4610,13 @@
4512 4610 <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo intval($stats['deferrals']); ?></div>
4513 4611 </div>
4514 4612 <div>
4515 4613 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Max Load Observed</div>
4516 - <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo round($stats['max_load'], 2); ?></div>
4614 + <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo esc_html((string) round($stats['max_load'], 2)); ?></div>
4517 4615 </div>
4518 4616 <div>
4519 4617 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Average Load</div>
4520 - <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo round($stats['avg_load'], 2); ?></div>
4618 + <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo esc_html((string) round($stats['avg_load'], 2)); ?></div>
4521 4619 </div>
4522 4620 </div>
4523 4621 </div>
4524 4622
@@ -5225,9 +5323,9 @@
5225 5323 */
5226 5324 public function display_page_builder_notice() {
5227 5325 // Only relevant on the plugin's own settings page — avoid repeating
5228 5326 // this notice across every admin screen.
5229 - if (!isset($_GET['page']) || $_GET['page'] !== self::$page_slug) {
5327 + if (!isset($_GET['page']) || wp_unslash($_GET['page']) !== self::$page_slug) {
5230 5328 return;
5231 5329 }
5232 5330
5233 5331 $configured = Metasync::get_option('general')['default_page_builder'] ?? '';
@@ -5243,9 +5341,9 @@
5243 5341 return;
5244 5342 }
5245 5343
5246 5344 // Handle dismiss action
5247 - if (isset($_GET['metasync_dismiss_builder_notice']) && wp_verify_nonce($_GET['_wpnonce'] ?? '', 'metasync_dismiss_builder')) {
5345 + if (isset($_GET['metasync_dismiss_builder_notice'], $_GET['_wpnonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'metasync_dismiss_builder')) {
5248 5346 update_user_meta(get_current_user_id(), 'metasync_builder_notice_dismissed', '1');
5249 5347 return;
5250 5348 }
5251 5349
@@ -5340,9 +5438,10 @@
5340 5438 * Saves the CPU load threshold and returns statistics
5341 5439 */
5342 5440 public function ajax_save_performance_settings() {
5343 5441 # Check nonce for security and return early if invalid
5344 - if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce($_POST['meta_sync_nonce'], 'meta_sync_general_setting_nonce')) {
5442 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5443 + if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce(wp_unslash($_POST['meta_sync_nonce']), 'meta_sync_general_setting_nonce')) {
5345 5444 wp_send_json_error(array('message' => 'Invalid nonce'));
5346 5445 return;
5347 5446 }
5348 5447
@@ -5410,9 +5509,9 @@
5410 5509 if (!wp_next_scheduled('metasync_cleanup_transients')) {
5411 5510 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_cleanup_transients');
5412 5511
5413 5512 if (!$scheduled) {
5414 - error_log('MetaSync: Failed to schedule transient cleanup cron job');
5513 + 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
5415 5514 }
5416 5515 }
5417 5516 }
5418 5517
@@ -5423,9 +5522,9 @@
5423 5522 {
5424 5523 $timestamp = wp_next_scheduled('metasync_cleanup_transients');
5425 5524 if ($timestamp) {
5426 5525 wp_unschedule_event($timestamp, 'metasync_cleanup_transients');
5427 - error_log('MetaSync: Transient cleanup cron job unscheduled');
5526 + error_log('MetaSync: Transient cleanup cron job unscheduled'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- unreachable in production
5428 5527 }
5429 5528 }
5430 5529
5431 5530 /**
@@ -5447,12 +5546,10 @@
5447 5546 {
5448 5547 if (!wp_next_scheduled('metasync_hidden_post_check')) {
5449 5548 $scheduled = wp_schedule_event(time(), 'metasync_weekly', 'metasync_hidden_post_check');
5450 5549
5451 - if ($scheduled) {
5452 - error_log('MetaSync: Hidden post manager cron job scheduled successfully (runs every 7 days)');
5453 - } else {
5454 - error_log('MetaSync: Failed to schedule hidden post manager cron job');
5550 + if (!$scheduled) {
5551 + 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
5455 5552 }
5456 5553 }
5457 5554 }
5458 5555
@@ -5463,12 +5560,10 @@
5463 5560 public function maybe_schedule_otto_recheck_404_cron()
5464 5561 {
5465 5562 if (!wp_next_scheduled('metasync_otto_recheck_404_exclusions')) {
5466 5563 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_otto_recheck_404_exclusions');
5467 - if ($scheduled) {
5468 - error_log('MetaSync: OTTO 404 recheck cron job scheduled successfully (runs daily)');
5469 - } else {
5470 - error_log('MetaSync: Failed to schedule OTTO 404 recheck cron job');
5564 + if (!$scheduled) {
5565 + 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
5471 5566 }
5472 5567 }
5473 5568 }
5474 5569
@@ -5541,13 +5636,13 @@
5541 5636
5542 5637 try {
5543 5638 // 1. Post revisions
5544 5639 if (!empty($settings['clean_post_revisions'])) {
5545 - $stats['post_revisions'] = (int) $wpdb->query(
5640 + $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
5546 5641 "DELETE FROM {$wpdb->posts} WHERE post_type = 'revision'"
5547 5642 );
5548 5643 // Remove postmeta left behind by deleted revisions
5549 - $wpdb->query(
5644 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5550 5645 "DELETE pm FROM {$wpdb->postmeta} pm
5551 5646 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5552 5647 WHERE p.ID IS NULL"
5553 5648 );
@@ -5555,20 +5650,19 @@
5555 5650
5556 5651 // 2. Trashed posts + their postmeta
5557 5652 if (!empty($settings['clean_trashed_posts'])) {
5558 5653 // Collect IDs first to cleanly remove postmeta
5559 - $trashed_ids = $wpdb->get_col(
5654 + $trashed_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5560 5655 "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'trash'"
5561 5656 );
5562 5657 if (!empty($trashed_ids)) {
5563 - $placeholders = implode(',', array_fill(0, count($trashed_ids), '%d'));
5564 - $wpdb->query(
5658 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5565 5659 $wpdb->prepare(
5566 - "DELETE FROM {$wpdb->postmeta} WHERE post_id IN ($placeholders)",
5660 + 'DELETE FROM ' . $wpdb->postmeta . ' WHERE post_id IN (' . implode(',', array_fill(0, count($trashed_ids), '%d')) . ')',
5567 5661 $trashed_ids
5568 5662 )
5569 5663 );
5570 - $stats['trashed_posts'] = (int) $wpdb->query(
5664 + $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
5571 5665 "DELETE FROM {$wpdb->posts} WHERE post_status = 'trash'"
5572 5666 );
5573 5667 } else {
5574 5668 $stats['trashed_posts'] = 0;
@@ -5576,9 +5670,9 @@
5576 5670 }
5577 5671
5578 5672 // 3. Trashed comments
5579 5673 if (!empty($settings['clean_trashed_comments'])) {
5580 - $stats['trashed_comments'] = (int) $wpdb->query(
5674 + $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
5581 5675 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'trash'"
5582 5676 );
5583 5677 }
5584 5678
@@ -5583,9 +5677,9 @@
5583 5677 }
5584 5678
5585 5679 // 4. Spam comments
5586 5680 if (!empty($settings['clean_spam_comments'])) {
5587 - $stats['spam_comments'] = (int) $wpdb->query(
5681 + $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
5588 5682 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'spam'"
5589 5683 );
5590 5684 }
5591 5685
@@ -5591,15 +5685,15 @@
5591 5685
5592 5686 // 5. Expired transients — direct SQL, no cache flush
5593 5687 if (!empty($settings['clean_expired_transients'])) {
5594 5688 // Delete timeout rows that have already expired
5595 - $wpdb->query(
5689 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5596 5690 "DELETE FROM {$wpdb->options}
5597 5691 WHERE option_name LIKE '\_transient\_timeout\_%'
5598 5692 AND option_value + 0 < UNIX_TIMESTAMP()"
5599 5693 );
5600 5694 // Delete value rows whose timeout row no longer exists
5601 - $stats['expired_transients'] = (int) $wpdb->query(
5695 + $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
5602 5696 "DELETE o FROM {$wpdb->options} o
5603 5697 LEFT JOIN {$wpdb->options} t
5604 5698 ON t.option_name = CONCAT('_transient_timeout_', SUBSTRING(o.option_name, 12))
5605 5699 WHERE o.option_name LIKE '\_transient\_%'
@@ -5609,9 +5703,9 @@
5609 5703 }
5610 5704
5611 5705 // 6. Orphaned postmeta (post_id references a post that no longer exists)
5612 5706 if (!empty($settings['clean_orphaned_postmeta'])) {
5613 - $stats['orphaned_postmeta'] = (int) $wpdb->query(
5707 + $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
5614 5708 "DELETE pm FROM {$wpdb->postmeta} pm
5615 5709 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5616 5710 WHERE p.ID IS NULL"
5617 5711 );
@@ -5623,12 +5717,10 @@
5623 5717 $settings['last_run_at'] = time();
5624 5718 $settings['last_run_stats'] = $stats;
5625 5719 update_option('metasync_db_cleanup_settings', $settings);
5626 5720
5627 - error_log('MetaSync: DB cleanup completed — ' . json_encode($stats));
5628 -
5629 5721 } catch (Exception $e) {
5630 - error_log('MetaSync: DB cleanup failed — ' . $e->getMessage());
5722 + error_log('MetaSync: DB cleanup failed — ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5631 5723 }
5632 5724 }
5633 5725
5634 5726 /**
@@ -5845,10 +5937,11 @@
5845 5937 /**
5846 5938 * AJAX: Save DB cleanup settings and reschedule cron accordingly.
5847 5939 */
5848 5940 public function ajax_save_db_cleanup_settings() {
5941 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5849 5942 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5850 - !wp_verify_nonce($_POST['db_cleanup_settings_nonce'], 'metasync_db_cleanup_settings_nonce')) {
5943 + !wp_verify_nonce(wp_unslash($_POST['db_cleanup_settings_nonce']), 'metasync_db_cleanup_settings_nonce')) {
5851 5944 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
5852 5945 return;
5853 5946 }
5854 5947
@@ -5905,10 +5998,11 @@
5905 5998 * AJAX: Manually trigger the DB cleanup and return stats for the UI.
5906 5999 * Persists current form state first so unsaved checkbox changes are respected.
5907 6000 */
5908 6001 public function ajax_run_db_cleanup() {
6002 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5909 6003 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5910 - !wp_verify_nonce($_POST['db_cleanup_settings_nonce'], 'metasync_db_cleanup_settings_nonce')) {
6004 + !wp_verify_nonce(wp_unslash($_POST['db_cleanup_settings_nonce']), 'metasync_db_cleanup_settings_nonce')) {
5911 6005 wp_send_json_error(array('message' => 'Invalid security token.'));
5912 6006 return;
5913 6007 }
5914 6008
@@ -5984,35 +6078,8 @@
5984 6078 ));
5985 6079 }
5986 6080
5987 6081 /**
5988 - * Control plugin auto-updates based on user setting
5989 - *
5990 - * @param bool $update Whether to update
5991 - * @param object $item Update offer
5992 - * @return bool Whether to allow auto-update
5993 - */
5994 - public function control_plugin_auto_updates($update, $item)
5995 - {
5996 - # Check if the item object has the slug property
5997 - if (!isset($item->slug)) {
5998 - return $update;
5999 - }
6000 -
6001 - // Check if this is our plugin
6002 - if ($item->slug === 'metasync') {
6003 - $general_settings = Metasync::get_option('general') ?? [];
6004 - $enable_auto_updates = $general_settings['enable_auto_updates'] ?? false;
6005 -
6006 - // Return the user's preference (true = allow auto-updates, false = prevent)
6007 - return $enable_auto_updates === 'true' || $enable_auto_updates === true;
6008 - }
6009 -
6010 - // For other plugins, don't interfere with their auto-update settings
6011 - return $update;
6012 - }
6013 -
6014 - /**
6015 6082 * AJAX handler to add excluded URL for OTTO
6016 6083 */
6017 6084 public function ajax_otto_add_excluded_url()
6018 6085 {
@@ -6158,8 +6225,9 @@
6158 6225 // If page has raw HTML or custom CSS from conversion, show badge
6159 6226 if ($has_raw_html || !empty($has_custom_css)) {
6160 6227 $label = $this->get_html_source_label();
6161 6228 $tooltip = sprintf(
6229 + /* translators: %s: HTML source label. */
6162 6230 __('This page was created using %s HTML-to-Builder converter', 'metasync'),
6163 6231 $label
6164 6232 );
6165 6233
@@ -6212,8 +6280,9 @@
6212 6280
6213 6281 if ($has_raw_html || !empty($has_custom_css)) {
6214 6282 $label = $this->get_html_source_label();
6215 6283 $message = sprintf(
6284 + /* translators: %s: HTML source label. */
6216 6285 __('This page was created using %s HTML-to-Builder converter. The design is preserved with custom CSS and inline styles.', 'metasync'),
6217 6286 '<strong>' . esc_html($label) . '</strong>'
6218 6287 );
6219 6288
@@ -6227,9 +6296,9 @@
6227 6296 <p class="metasync-editor-message">%s</p>
6228 6297 </div>
6229 6298 </div>',
6230 6299 esc_html($label),
6231 - $message
6300 + wp_kses($message, array('strong' => array()))
6232 6301 );
6233 6302 }
6234 6303 }
6235 6304
@@ -6252,9 +6321,9 @@
6252 6321 ?>
6253 6322 <fieldset class="inline-edit-col-left metasync-quick-edit-source">
6254 6323 <div class="inline-edit-col">
6255 6324 <label>
6256 - <span class="title"><?php _e('Source', 'metasync'); ?></span>
6325 + <span class="title"><?php esc_html_e('Source', 'metasync'); ?></span>
6257 6326 <span class="metasync-quick-edit-badge-container"></span>
6258 6327 </label>
6259 6328 </div>
6260 6329 </fieldset>
@@ -6266,8 +6335,9 @@
6266 6335 */
6267 6336 public function add_html_pages_dashboard_widget()
6268 6337 {
6269 6338 $label = $this->get_html_source_label();
6339 + /* translators: %s: HTML source label. */
6270 6340 $widget_title = sprintf(__('%s Pages', 'metasync'), $label);
6271 6341
6272 6342 wp_add_dashboard_widget(
6273 6343 'metasync_html_pages_widget',
@@ -6405,44 +6475,8 @@
6405 6475
6406 6476 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
6407 6477 $bing_instant_index = new Metasync_Bing_Instant_Index();
6408 6478 $bing_instant_index->send();
6409 - }
6410 -
6411 - /**
6412 - * Save instant indexing settings (Google and Bing)
6413 - *
6414 - * @since 2.6.0
6415 - * @return void
6416 - */
6417 - public function save_instant_indexing_settings()
6418 - {
6419 - // Check if this is a settings submission
6420 - if (!isset($_POST['submit'])) {
6421 - return;
6422 - }
6423 -
6424 - // This handler runs on admin_init, which also fires inside
6425 - // admin-ajax.php before its login gate, so a bare isset() check let
6426 - // any request (including logged-out ones) rewrite the auto-submit
6427 - // post types. Require the nonce and plugin access before writing.
6428 - if (!isset($_POST['metasync_instant_indexing_nonce'])
6429 - || !wp_verify_nonce(sanitize_key(wp_unslash($_POST['metasync_instant_indexing_nonce'])), 'metasync_instant_indexing_settings')
6430 - || !Metasync::current_user_has_plugin_access()) {
6431 - return;
6432 - }
6433 -
6434 - // Save post types for Google Instant Indexing auto-submit
6435 - if (isset($_POST['metasync_post_types'])) {
6436 - $post_data = metasync_sanitize_input_array($_POST);
6437 - $post_types = is_array($post_data['metasync_post_types']) ? array_map('sanitize_title', $post_data['metasync_post_types']) : [];
6438 -
6439 - $settings = get_option('metasync_options_instant_indexing', ['post_types' => []]);
6440 - $settings['post_types'] = array_values($post_types);
6441 - update_option('metasync_options_instant_indexing', $settings);
6442 - }
6443 -
6444 - // Note: Bing Instant Indexing settings are saved via AJAX in save_bing_inline_settings_ajax()
6445 6479 }
6446 6480
6447 6481 /**
6448 6482 * Save Bing instant indexing settings from inline form (Indexation Control page)