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 +504 -352 2.6.16 → 2.7.1 View file →
@@ -33,9 +33,8 @@
33 33 const SECTION_METASYNC = "metasync_settings";
34 34 const SECTION_SEARCHENGINE = "searchengine_settings";
35 35 const SECTION_LOCALSEO = "local_seo";
36 36 const SECTION_CODESNIPPETS = "code_snippets";
37 - const SECTION_OPTIMAL_SETTINGS = "optimal_settings";
38 37 const SECTION_SITE_SETTINGS = "site_settings";
39 38 const SECTION_COMMON_SETTINGS = "common_settings";
40 39 const SECTION_COMMON_META_SETTINGS = "common_meta_settings";
41 40 const SECTION_SOCIAL_META = "social_meta";
@@ -42,9 +41,8 @@
42 41 const SECTION_SEO_CONTROLS = "seo_controls";
43 42 const SECTION_SEO_CONTROLS_ADVANCED = "seo_controls_advanced";
44 43 const SECTION_SEO_CONTROLS_INSTANT_INDEX = "seo_controls_instant_index";
45 44 const SECTION_PLUGIN_VISIBILITY = "plugin_visibility_settings";
46 - const SECTION_BREADCRUMBS = "breadcrumbs_settings";
47 45 const SECTION_LLMS_TXT = "llms_txt_settings";
48 46
49 47 /**
50 48 * The ID of this plugin.
@@ -200,9 +198,9 @@
200 198 // Set menu title using the effective title (includes whitelabel company name if available)
201 199 $this->menu_title = $this->get_effective_menu_title();
202 200 $raw_slug = isset($data['white_label_plugin_menu_slug']) ? $data['white_label_plugin_menu_slug'] : '';
203 201 $clean_slug = sanitize_title($raw_slug);
204 - # Self-heal a legacy URL-shaped slug (WP-413): persist the sanitized value so every
202 + # Self-heal a legacy URL-shaped slug: persist the sanitized value so every
205 203 # reader that builds admin links from the stored option gets a valid WP menu slug.
206 204 if ($raw_slug !== '' && $clean_slug !== $raw_slug) {
207 205 $options = Metasync::get_option();
208 206 $options['general']['white_label_plugin_menu_slug'] = $clean_slug;
@@ -214,9 +212,8 @@
214 212 add_action('admin_menu', array($this, 'add_import_external_data_page'));
215 213 add_action('admin_init', array($this, 'settings_page_init'));
216 214 add_filter('all_plugins', array($this,'metasync_plugin_white_label'));
217 215 add_filter( 'plugin_row_meta',array($this,'metasync_view_detials_url'),10,3);
218 - add_filter('site_transient_update_plugins', array($this, 'inject_whitelabel_icon_into_update_transient'));
219 216
220 217 // Display transient error/success messages for redirections
221 218 add_action('admin_notices', array($this, 'display_redirection_messages'));
222 219
@@ -265,8 +262,9 @@
265 262 add_action('metasync_api_key_changed', array('Metasync_Admin_Navigation', 'invalidate_admin_bar_status_cache'), 10, 0);
266 263
267 264 add_action('admin_init', array($this, 'initialize_cookie'));
268 265 add_action('admin_init', array($this, 'maybe_redirect_to_wizard'));
266 + add_action('admin_init', array($this, 'redirect_legacy_instant_indexing_page'));
269 267
270 268 // Add admin_post hooks for form submissions (WordPress standard way - no output buffering needed)
271 269 add_action('admin_post_metasync_clear_all_cache_plugins', array($this, 'handle_clear_all_cache_plugins'));
272 270 add_action('admin_post_metasync_clear_otto_cache_all', array($this, 'handle_clear_otto_cache_all'));
@@ -284,8 +282,11 @@
284 282
285 283 // Add AJAX for saving execution settings
286 284 add_action( 'wp_ajax_metasync_save_execution_settings', array($this, 'ajax_save_execution_settings') );
287 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 +
288 289 // Add AJAX for saving hosting cache settings
289 290 add_action( 'wp_ajax_metasync_save_hosting_cache_settings', array($this, 'ajax_save_hosting_cache_settings') );
290 291
291 292 // Add AJAX for saving OTTO Cache TTL
@@ -315,8 +316,9 @@
315 316 add_action('wp_ajax_metasync_get_default_robots', array($this, 'ajax_get_default_robots'));
316 317 add_action('wp_ajax_metasync_preview_robots_backup', array($this, 'ajax_preview_robots_backup'));
317 318 add_action('wp_ajax_metasync_delete_robots_backup', array($this, 'ajax_delete_robots_backup'));
318 319 add_action('wp_ajax_metasync_restore_robots_backup', array($this, 'ajax_restore_robots_backup'));
320 + add_action('wp_ajax_metasync_get_robots_backups', array($this, 'ajax_get_robots_backups'));
319 321
320 322 // Add AJAX handlers for host blocking test
321 323 add_action('wp_ajax_metasync_test_host_blocking_get', array($this, 'ajax_test_host_blocking_get'));
322 324 add_action('wp_ajax_metasync_test_host_blocking_post', array($this, 'ajax_test_host_blocking_post'));
@@ -373,8 +375,15 @@
373 375 add_action('wp_ajax_metasync_process_batch_tick', array($this, 'ajax_process_batch_tick'));
374 376 add_action('wp_ajax_metasync_delete_orphaned_image', array($this, 'ajax_delete_orphaned_image'));
375 377 add_action('metasync_media_batch_optimize_cron', array($this, 'handle_media_batch_cron'));
376 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 +
377 386 # Add AJAX handlers for Google Instant Indexing
378 387 add_action('wp_ajax_metasync_send_giapi', array($this, 'ajax_send_giapi'));
379 388
380 389 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
@@ -467,9 +476,9 @@
467 476
468 477 $data= Metasync::get_option('general');
469 478
470 479 # Get white label menu slug
471 - # Sanitize so a URL-shaped legacy value still yields a valid WP menu slug (WP-413)
480 + # Sanitize so a URL-shaped legacy value still yields a valid WP menu slug
472 481 $menu_slug = empty($data['white_label_plugin_menu_slug']) ? self::$page_slug : sanitize_title($data['white_label_plugin_menu_slug']);
473 482 $menu_slug = $menu_slug === '' ? self::$page_slug : $menu_slug;
474 483
475 484 ?>
@@ -488,9 +497,9 @@
488 497 <?php
489 498 }
490 499
491 500 public function metasync_fouc_prevention_style() {
492 - 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
493 502 return;
494 503 }
495 504 ?>
496 505 <style>
@@ -498,9 +507,9 @@
498 507 /*
499 508 * WP prints admin notices at the top of #wpbody-content, then core's
500 509 * common.js relocates them next to .wp-header-end inside our wrap on
501 510 * DOM-ready. Keep them out of the flow until that move so they don't
502 - * flash above the dashboard header and shove the page down (WP-496).
511 + * flash above the dashboard header and shove the page down.
503 512 * Once relocated they're no longer a direct child here, so this rule
504 513 * stops matching and they fade in with the wrap. We mirror core's
505 514 * relocation set exactly (.notice/.updated/.error, minus .inline and
506 515 * .below-h2); .update-nag is left alone since core never moves it.
@@ -512,13 +521,13 @@
512 521 <?php
513 522 }
514 523
515 524 public function suppress_notices_on_wizard_page() {
516 - 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
517 526 return;
518 527 }
519 528
520 - $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
521 530
522 531 // Setup wizard: strip every notice for a fully focused, distraction-free screen.
523 532 if ( strpos( $page, '-setup-wizard' ) !== false ) {
524 533 remove_all_actions( 'admin_notices' );
@@ -636,9 +645,9 @@
636 645
637 646 #Redirect url to the new slug
638 647 $redirect_url = admin_url('admin.php?page=' . $current_slug);
639 648
640 - wp_redirect($redirect_url);
649 + wp_safe_redirect($redirect_url);
641 650 exit;
642 651 }
643 652 }
644 653
@@ -690,13 +699,13 @@
690 699 * @since 1.0.0
691 700 */
692 701 public function maybe_redirect_to_wizard() {
693 702 // Only redirect if wizard should be shown
694 - 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
695 704 delete_option('metasync_show_wizard');
696 705
697 706 // Don't redirect during AJAX, cron, or bulk activation
698 - 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
699 708 return;
700 709 }
701 710
702 711 // Only redirect if user has access to the plugin
@@ -708,11 +717,56 @@
708 717 exit;
709 718 }
710 719 }
711 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 +
712 765 public function metasync_display_error_log() {
713 766 Metasync_Debug_Manager::instance()->metasync_display_error_log($this);
714 767 }
768 +
715 769 public function metasync_update_wp_config() {
716 770 Metasync_Debug_Manager::instance()->metasync_update_wp_config();
717 771 }
718 772
@@ -755,9 +809,9 @@
755 809 ]);
756 810 $_COOKIE['metasync_previous_slug'] = $new_slug;
757 811 // Redirect to the new slug
758 812 $redirect_url = admin_url('admin.php?page=' . $old_slug);
759 - wp_redirect($redirect_url);
813 + wp_safe_redirect($redirect_url);
760 814 exit;
761 815 }else{
762 816 self::$page_slug = Metasync::get_option('general')['white_label_plugin_menu_slug']=="" ? "searchatlas":Metasync::get_option('general')['white_label_plugin_menu_slug'];
763 817 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
@@ -763,9 +817,9 @@
763 817 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
764 818
765 819 #add redirection for when the old slug is not defined
766 820 #this fixes the redirect issue #
767 - wp_redirect($redirect_url);
821 + wp_safe_redirect($redirect_url);
768 822 exit;
769 823 }
770 824 }
771 825 public function metasync_view_detials_url( $plugin_meta, $plugin_file, $plugin_data ) {
@@ -776,9 +830,10 @@
776 830 if (strpos($meta, 'open-plugin-details-modal') !== false) {
777 831 $meta = sprintf(
778 832 '<a href="%s" class="thickbox open-plugin-details-modal" aria-label="%s" data-title="%s">%s</a>',
779 833 add_query_arg('TB_iframe', 'true', $plugin_uri),
780 - 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'])),
781 836 esc_attr($plugin_data['Name']),
782 837 esc_html__('View details', 'metasync')
783 838 );
784 839 break; // Exit loop after replacing the link
@@ -826,53 +881,8 @@
826 881 return $all_plugins;
827 882 }
828 883
829 884 /**
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 885 * Register the stylesheets for the admin area.
876 886 *
877 887 * @since 1.0.0
878 888 */
@@ -877,9 +887,9 @@
877 887 * @since 1.0.0
878 888 */
879 889 public function enqueue_styles()
880 890 {
881 - $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
882 892
883 893 if (strpos($current_page, self::$page_slug) === 0) {
884 894 wp_enqueue_style(
885 895 $this->plugin_name,
@@ -908,9 +918,9 @@
908 918 );
909 919 }
910 920
911 921 // Enqueue wizard CSS if on wizard page
912 - 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
913 923 wp_enqueue_style(
914 924 $this->plugin_name . '-setup-wizard',
915 925 plugin_dir_url(__FILE__) . 'css/metasync-setup-wizard.css',
916 926 array($this->plugin_name . '-dashboard'),
@@ -919,9 +929,9 @@
919 929 );
920 930 }
921 931
922 932 // Enqueue SEO Health CSS if on the SEO Health page
923 - 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
924 934 wp_enqueue_style(
925 935 $this->plugin_name . '-seo-health',
926 936 plugin_dir_url(__FILE__) . 'css/metasync-seo-health.css',
927 937 array($this->plugin_name . '-dashboard'),
@@ -938,9 +948,9 @@
938 948 */
939 949 public function enqueue_scripts()
940 950 {
941 951 // --- Phase 5 (#887): Extracted inline JS files ---
942 - $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
943 953 $plugin_root_url = plugin_dir_url(dirname(__FILE__));
944 954 $is_metasync_page = ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0);
945 955
946 956 if ($is_metasync_page) {
@@ -970,10 +980,49 @@
970 980 array('jquery'),
971 981 $this->version,
972 982 true
973 983 );
984 +
985 + // Keeps core's admin-menu pinning in step with pages whose height
986 + // changes after load. Depends on 'common' so core has bound its
987 + // own handlers before this runs.
988 + wp_enqueue_script(
989 + $this->plugin_name . '-admin-menu-height',
990 + plugin_dir_url(__FILE__) . 'js/metasync-admin-menu-height.js',
991 + array('jquery', 'common'),
992 + $this->version,
993 + true
994 + );
974 995 }
975 996
997 + $per_page_screens = [
998 + self::$page_slug . '-redirections',
999 + self::$page_slug . '-404-monitor',
1000 + self::$page_slug . '-sync-log',
1001 + self::$page_slug . '-media-optimization',
1002 + ];
1003 +
1004 + if (in_array($current_page, $per_page_screens, true)) {
1005 + // Results-per-page selectors use this external handler instead of
1006 + // an inline event attribute so changing a page size always reloads
1007 + // the current admin page.
1008 + wp_enqueue_script(
1009 + $this->plugin_name . '-per-page',
1010 + plugin_dir_url(__FILE__) . 'js/metasync-per-page.js',
1011 + array(),
1012 + $this->version,
1013 + true
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 + );
1023 + }
1024 +
976 1025 // Dashboard iframe height (only on dashboard page)
977 1026 if ($current_page === self::$page_slug . '-dashboard' || $current_page === self::$page_slug) {
978 1027 wp_enqueue_script(
979 1028 $this->plugin_name . '-iframe',
@@ -1022,12 +1071,12 @@
1022 1071 true
1023 1072 );
1024 1073 // Pass the active tab from server (handles POST redirect)
1025 1074 $sitemap_active_tab = 'general';
1026 - if (isset($_GET['tab']) && in_array($_GET['tab'], ['general', 'news', 'video'], true)) {
1027 - $sitemap_active_tab = sanitize_text_field(wp_unslash($_GET['tab']));
1028 - } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) {
1029 - $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
1030 1079 }
1031 1080 wp_localize_script($this->plugin_name . '-sitemap-tabs', 'metasyncSitemapTabs', [
1032 1081 'activeTab' => $sitemap_active_tab,
1033 1082 ]);
@@ -1355,8 +1404,10 @@
1355 1404 'nonce' => wp_create_nonce('metasync_nonce'),
1356 1405 'sa_connect_nonce' => $sa_connect_nonce,
1357 1406 'reset_auth_nonce' => wp_create_nonce('metasync_reset_auth_nonce'),
1358 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'),
1359 1410 'heartbeat_state' => $heartbeat_state,
1360 1411 'dashboard_domain' => self::get_effective_dashboard_domain(),
1361 1412 'support_email' => Metasync::SUPPORT_EMAIL,
1362 1413 'documentation_domain' => Metasync::DOCUMENTATION_DOMAIN,
@@ -1420,9 +1471,9 @@
1420 1471 add_action('admin_notices', array($this, 'display_page_builder_notice'));
1421 1472 // Display update warning banner if plugin update is available
1422 1473 add_action('admin_notices', array($this, 'display_update_warning_banner'));
1423 1474 // Enqueue wizard assets if on wizard page
1424 - 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
1425 1476 wp_enqueue_script(
1426 1477 $this->plugin_name . '-setup-wizard',
1427 1478 plugin_dir_url(__FILE__) . 'js/metasync-setup-wizard.js',
1428 1479 array('jquery'),
@@ -1446,14 +1497,44 @@
1446 1497 }
1447 1498
1448 1499 /**
1449 1500 * Settings of HeartBeat API for admin area.
1450 - * Set time interval of send request.
1501 + *
1502 + * The only thing the plugin rides the beat for is a periodic admin
1503 + * telemetry ping (the `heartbeat-send` handler in admin/js/metasync-admin.js),
1504 + * so it slows the beat down rather than speeding it up. Two contexts are
1505 + * left on whatever core decided, because core leans on the beat there:
1506 + *
1507 + * - The front end, where the beat drives wp-auth-check — the "your session
1508 + * has expired, log in again" prompt.
1509 + * - The editor screens, where each tick renews the post lock. A lock only
1510 + * lives for wp_check_post_lock_window() seconds (150 by default), so any
1511 + * interval longer than that drops the lock between beats and lets a
1512 + * second editor silently take over a post someone still has open.
1513 + *
1514 + * Core clamps this to 1-3600 seconds in wp-includes/js/heartbeat.js, so an
1515 + * over-long interval is not corrected on our behalf.
1516 + *
1517 + * @param array $settings Heartbeat settings array.
1518 + * @return array Heartbeat settings array.
1451 1519 */
1452 1520 function metasync_heartbeat_settings($settings)
1453 1521 {
1454 - global $heartbeat_frequency;
1455 - $settings['interval'] = 300;
1522 + if (!is_admin()) {
1523 + return $settings;
1524 + }
1525 +
1526 + # $pagenow is set in wp-includes/vars.php, long before this filter runs;
1527 + # core's own wp_heartbeat_set_suspension() reads it exactly this way.
1528 + global $pagenow;
1529 + $lock_dependent_screens = array('post.php', 'post-new.php', 'customize.php');
1530 + if (isset($pagenow) && in_array($pagenow, $lock_dependent_screens, true)) {
1531 + return $settings;
1532 + }
1533 +
1534 + # 60s matches heartbeat.js's own default and stays well inside the
1535 + # 150s post-lock window, so nothing core does can fall through a gap.
1536 + $settings['interval'] = 60;
1456 1537 return $settings;
1457 1538 }
1458 1539
1459 1540 /**
@@ -1591,13 +1672,8 @@
1591 1672 {
1592 1673 Metasync_Connect_Manager::instance()->test_searchatlas_ajax_endpoint();
1593 1674 }
1594 1675
1595 - public function simple_ajax_test()
1596 - {
1597 - Metasync_Connect_Manager::instance()->simple_ajax_test();
1598 - }
1599 -
1600 1676 /**
1601 1677 * Create Admin Dashboard Iframe Page
1602 1678 * Embeds the Search Atlas dashboard directly in WordPress admin
1603 1679 */
@@ -1626,9 +1702,9 @@
1626 1702 <!-- OTTO Cache TTL Setting -->
1627 1703 <div style="margin-bottom: 30px; padding-top: 20px;">
1628 1704 <h4 style="margin-top: 0; color: var(--dashboard-text-primary);"><?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> Cache TTL</h4>
1629 1705 <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">
1630 - Configure how long <?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> API suggestions are cached before a fresh API call. Stale cache expires at 2× this value.
1706 + Configure how long <?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> API suggestions are cached before a fresh API call. The stale fallback expires after 12 hours.
1631 1707 </p>
1632 1708
1633 1709 <div style="background: rgba(255,255,255,0.02); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
1634 1710 <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
@@ -1634,8 +1710,9 @@
1634 1710 <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
1635 1711 <label for="metasync-otto-cache-ttl" style="color: var(--dashboard-text-primary); font-weight: 500;">
1636 1712 Cache TTL (minutes):
1637 1713 </label>
1714 + <?php Metasync::render_tooltip_icon('otto_cache_ttl', 'How many minutes OTTO reuses its last SEO suggestions before fetching fresh ones. 30 is fine for most sites.'); ?>
1638 1715 <input type="number"
1639 1716 id="metasync-otto-cache-ttl"
1640 1717 value="<?php echo esc_attr($this->get_otto_cache_ttl_minutes()); ?>"
1641 1718 min="30"
@@ -1683,9 +1760,9 @@
1683 1760 } else {
1684 1761 echo '<p style="color: var(--dashboard-text-secondary);">ℹ️ No cache plugins detected.</p>';
1685 1762 }
1686 1763 } catch (Exception $e) {
1687 - 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
1688 1765 echo '<p style="color: var(--dashboard-error);">⚠️ An error occurred while retrieving cache plugin status.</p>';
1689 1766 }
1690 1767 } else {
1691 1768 echo '<p style="color: var(--dashboard-error);">⚠️ Cache Purge class not loaded.</p>';
@@ -1702,12 +1779,12 @@
1702 1779 </form>
1703 1780
1704 1781 <?php
1705 1782 // Display success/error messages
1706 - if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') {
1707 - $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0;
1708 - $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0;
1709 - $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
1710 1787
1711 1788 if ($cleared > 0) {
1712 1789 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1713 1790 echo '✅ <strong>Success!</strong> Cleared cache for ' . intval( $cleared ) . ' plugin(s)';
@@ -1727,10 +1804,10 @@
1727 1804 echo '</p></div>';
1728 1805 }
1729 1806 }
1730 1807
1731 - if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') {
1732 - $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
1733 1810 if (empty($message)) {
1734 1811 $message = 'An unknown error occurred while clearing cache. Please check error logs for details.';
1735 1812 }
1736 1813 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
@@ -1779,8 +1856,9 @@
1779 1856 <?php checked(true, !empty($hosting_settings['wpengine_enabled'])); ?>
1780 1857 <?php echo !$wpe_detected ? 'disabled' : ''; ?>
1781 1858 style="width: 16px; height: 16px; cursor: <?php echo $wpe_detected ? 'pointer' : 'not-allowed'; ?>;" />
1782 1859 <span style="color: var(--dashboard-text-primary); font-weight: 500;">WP Engine</span>
1860 + <?php Metasync::render_tooltip_icon('hosting_cache_wpengine', 'If hosted on WP Engine, turn on so OTTO clears the host\'s built-in cache when it updates a page.'); ?>
1783 1861 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges Varnish + Memcached</span>
1784 1862 </label>
1785 1863
1786 1864 <label style="display: flex; align-items: center; gap: 10px; margin-bottom: 16px; cursor: pointer;">
@@ -1789,8 +1867,9 @@
1789 1867 <?php checked(true, !empty($hosting_settings['kinsta_enabled'])); ?>
1790 1868 <?php echo !$kinsta_detected ? 'disabled' : ''; ?>
1791 1869 style="width: 16px; height: 16px; cursor: <?php echo $kinsta_detected ? 'pointer' : 'not-allowed'; ?>;" />
1792 1870 <span style="color: var(--dashboard-text-primary); font-weight: 500;">Kinsta</span>
1871 + <?php Metasync::render_tooltip_icon('hosting_cache_kinsta', 'If hosted on Kinsta, turn on so OTTO clears Kinsta\'s server cache automatically.'); ?>
1793 1872 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges full-page cache (kinsta_cache_purge_full)</span>
1794 1873 </label>
1795 1874
1796 1875 <div style="display: flex; align-items: center; gap: 12px;">
@@ -1825,12 +1904,13 @@
1825 1904 </form>
1826 1905
1827 1906 <?php
1828 1907 // Hosting cache result messages
1829 - if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') {
1830 - $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode($_GET['hc_cleared'])) : '';
1831 - $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode($_GET['hc_failed'])) : '';
1832 - $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
1833 1913
1834 1914 if ($hc_cleared) {
1835 1915 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1836 1916 echo '✅ <strong>Success!</strong> Purged hosting cache on: ' . esc_html($hc_cleared);
@@ -1845,8 +1925,9 @@
1845 1925 echo '<div class="notice notice-info inline" style="margin-top: 10px;"><p>';
1846 1926 echo 'ℹ️ No enabled hosting providers were detected on this server (' . esc_html($hc_not_detected) . ').';
1847 1927 echo '</p></div>';
1848 1928 }
1929 + // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1849 1930 }
1850 1931 ?>
1851 1932
1852 1933 <script>
@@ -1938,9 +2019,9 @@
1938 2019 id="metasync-targeted-object-cache"
1939 2020 <?php checked('1', $targeted_cache_enabled); ?>
1940 2021 style="width: 16px; height: 16px; cursor: pointer; margin-top: 2px; flex-shrink: 0;" />
1941 2022 <span>
1942 - <span style="color: var(--dashboard-text-primary); font-weight: 500; display: block; margin-bottom: 4px;">Targeted Object Cache Purge</span>
2023 + <span style="color: var(--dashboard-text-primary); font-weight: 500; display: block; margin-bottom: 4px;">Targeted Object Cache Purge<?php Metasync::render_tooltip_icon('targeted_object_cache_purge', 'Leave this on. Refreshes only the pages OTTO changed instead of wiping your whole site\'s memory cache — safer/faster on large sites.'); ?></span>
1943 2024 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">
1944 2025 When enabled, only the updated posts are evicted from the object cache (recommended for large sites).
1945 2026 When disabled, a full <code>wp_cache_flush()</code> is used instead.
1946 2027 </span>
@@ -2011,11 +2092,11 @@
2011 2092 </div>
2012 2093
2013 2094 <?php
2014 2095 // Display success/error messages
2015 - if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') {
2016 - $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0;
2017 - $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
2018 2099
2019 2100 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
2020 2101 if (!empty($url)) {
2021 2102 echo '✅ <strong>Success!</strong> Cleared cache for URL: <code>' . esc_html($url) . '</code> (' . intval( $cleared_count ) . ' entries)';
@@ -2024,10 +2105,10 @@
2024 2105 }
2025 2106 echo '</p></div>';
2026 2107 }
2027 2108
2028 - if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') {
2029 - $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
2030 2111 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
2031 2112 echo '❌ <strong>Error:</strong> ' . esc_html($message);
2032 2113 echo '</p></div>';
2033 2114 }
@@ -2066,9 +2147,9 @@
2066 2147 <td>
2067 2148 <input type="url"
2068 2149 id="otto_cache_url"
2069 2150 name="otto_cache_url"
2070 - 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 ?>"
2071 2152 class="regular-text"
2072 2153 placeholder="https://example.com/page/"
2073 2154 required />
2074 2155 <p class="description" style="color: var(--dashboard-text-secondary);">Enter the full URL of the page whose cache you want to clear.</p>
@@ -2139,9 +2220,10 @@
2139 2220 /**
2140 2221 * AJAX handler for saving hosting cache settings
2141 2222 */
2142 2223 public function ajax_save_hosting_cache_settings() {
2143 - 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')) {
2144 2226 wp_send_json_error(array('message' => 'Security check failed'));
2145 2227 return;
2146 2228 }
2147 2229
@@ -2162,9 +2244,10 @@
2162 2244 /**
2163 2245 * AJAX handler: save object cache behaviour settings
2164 2246 */
2165 2247 public function ajax_save_object_cache_settings() {
2166 - 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')) {
2167 2250 wp_send_json_error(array('message' => 'Security check failed'));
2168 2251 return;
2169 2252 }
2170 2253
@@ -2191,9 +2274,10 @@
2191 2274 /**
2192 2275 * AJAX handler for saving OTTO Cache TTL
2193 2276 */
2194 2277 public function ajax_save_otto_cache_ttl() {
2195 - 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')) {
2196 2280 wp_send_json_error(array('message' => 'Security check failed.'));
2197 2281 return;
2198 2282 }
2199 2283
@@ -2219,9 +2303,10 @@
2219 2303 /**
2220 2304 * admin_post handler: purge WP Engine and Kinsta hosting-level caches
2221 2305 */
2222 2306 public function handle_purge_hosting_cache() {
2223 - 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')) {
2224 2309 wp_die('Security check failed');
2225 2310 }
2226 2311
2227 2312 if (!Metasync::current_user_has_plugin_access()) {
@@ -2241,9 +2326,9 @@
2241 2326 WpeCommon::purge_varnish_cache();
2242 2327 WpeCommon::purge_memcached();
2243 2328 $cleared[] = 'WP Engine';
2244 2329 } catch (Exception $e) {
2245 - 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
2246 2331 $failed[] = 'WP Engine';
2247 2332 }
2248 2333 } else {
2249 2334 $not_detected[] = 'WP Engine';
@@ -2256,9 +2341,9 @@
2256 2341 try {
2257 2342 KinstaCache::get_instance()->kinsta_cache_purge_full();
2258 2343 $cleared[] = 'Kinsta';
2259 2344 } catch (Exception $e) {
2260 - 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
2261 2346 $failed[] = 'Kinsta';
2262 2347 }
2263 2348 } else {
2264 2349 $not_detected[] = 'Kinsta';
@@ -2653,9 +2738,9 @@
2653 2738 # Initialize MetaSync API request class and trigger synchronization
2654 2739 (new Metasync_Sync_Requests())->SyncCustomerParams();
2655 2740 } catch (Exception $e) {
2656 2741 # Log any API request errors for debugging
2657 - 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
2658 2743 }
2659 2744 }
2660 2745
2661 2746 /*
@@ -2670,9 +2755,9 @@
2670 2755 # Initialize MetaSync API request class and trigger synchronization
2671 2756 (new Metasync_Sync_Requests())->SyncCustomerParams();
2672 2757 } catch (Exception $e) {
2673 2758 # Log any API request errors for debugging
2674 - 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
2675 2760 }
2676 2761 }
2677 2762
2678 2763 /**
@@ -2705,8 +2790,15 @@
2705 2790 // Handle form submissions
2706 2791 if (isset($_POST['metasync_media_optimization_nonce'])) {
2707 2792 check_admin_referer('metasync_save_media_optimization', 'metasync_media_optimization_nonce');
2708 2793
2794 + // Settings shape the whole site's conversion pipeline — restrict
2795 + // the save (and the reset) to site admins, not just anyone who can
2796 + // open the page via plugin_access_roles.
2797 + if (!current_user_can('manage_options')) {
2798 + wp_die(esc_html__('Sorry, you are not allowed to manage media optimization settings.', 'metasync'));
2799 + }
2800 +
2709 2801 // Handle reset to defaults
2710 2802 if (!empty($_POST['metasync_media_reset'])) {
2711 2803 $defaults = Metasync_Media_Settings::get_defaults();
2712 2804 Metasync_Media_Settings::save_settings($defaults);
@@ -2711,8 +2803,9 @@
2711 2803 $defaults = Metasync_Media_Settings::get_defaults();
2712 2804 Metasync_Media_Settings::save_settings($defaults);
2713 2805 $save_success = true;
2714 2806 } elseif (isset($_POST['metasync_media'])) {
2807 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Media_Settings::save_settings().
2715 2808 $input = wp_unslash($_POST['metasync_media']);
2716 2809 Metasync_Media_Settings::save_settings($input);
2717 2810 $save_success = true;
2718 2811 }
@@ -2744,8 +2837,15 @@
2744 2837 * Code Minification page callback
2745 2838 */
2746 2839 public function create_admin_code_minification_page()
2747 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 +
2748 2848 $this->render_layout_open('Code Minification', 'code_minification', 'Minify CSS, JavaScript, and HTML to improve performance.');
2749 2849 // Load settings and compatibility classes
2750 2850 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-settings.php';
2751 2851 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-cache.php';
@@ -2762,8 +2862,9 @@
2762 2862 $defaults = Metasync_Minification_Settings::get_defaults();
2763 2863 Metasync_Minification_Settings::save_settings($defaults);
2764 2864 $save_success = true;
2765 2865 } elseif (isset($_POST['metasync_code_min'])) {
2866 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Minification_Settings::save_settings().
2766 2867 $input = (array) wp_unslash($_POST['metasync_code_min']);
2767 2868 Metasync_Minification_Settings::save_settings($input);
2768 2869 $save_success = true;
2769 2870 }
@@ -2796,8 +2897,15 @@
2796 2897 if (!$attachment_id) {
2797 2898 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2798 2899 }
2799 2900
2901 + // Ownership: under the replace strategy this permanently deletes the
2902 + // attachment's original file, so upload_files alone is not enough —
2903 + // the caller must be allowed to edit this specific attachment.
2904 + if (!current_user_can('edit_post', $attachment_id)) {
2905 + wp_send_json_error(__('Permission denied.', 'metasync'));
2906 + }
2907 +
2800 2908 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-settings.php';
2801 2909 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-image-converter.php';
2802 2910 $settings = Metasync_Media_Settings::get_settings();
2803 2911
@@ -2811,8 +2919,9 @@
2811 2919 $max_bytes = 10 * 1024 * 1024;
2812 2920 if (filesize($file) > $max_bytes) {
2813 2921 $size_mb = round(filesize($file) / 1024 / 1024, 1);
2814 2922 wp_send_json_error(sprintf(
2923 + /* translators: %s: file size in megabytes. */
2815 2924 __('Optimization skipped: file size (%s MB) exceeds the 10 MB safety limit to prevent memory issues.', 'metasync'),
2816 2925 $size_mb
2817 2926 ));
2818 2927 }
@@ -2849,8 +2958,20 @@
2849 2958 if (!$attachment_id) {
2850 2959 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2851 2960 }
2852 2961
2962 + // Ownership: reverting deletes the attachment's converted files, so
2963 + // restrict it to users who may edit this specific attachment.
2964 + if (!current_user_can('edit_post', $attachment_id)) {
2965 + wp_send_json_error(__('Permission denied.', 'metasync'));
2966 + }
2967 +
2968 + // Replace-strategy conversions have no original to restore — reverting
2969 + // would delete the attachment's only file. Refuse up front, like bulk revert does.
2970 + if (!Metasync_Image_Converter::can_revert($attachment_id)) {
2971 + wp_send_json_error(__('This image cannot be reverted. The original file no longer exists (replace strategy).', 'metasync'));
2972 + }
2973 +
2853 2974 $success = Metasync_Image_Converter::revert_attachment($attachment_id);
2854 2975
2855 2976 if ($success) {
2856 2977 wp_send_json_success();
@@ -2902,8 +3023,15 @@
2902 3023 public function ajax_batch_progress()
2903 3024 {
2904 3025 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
2905 3026
3027 + // Read-only progress/stats polling — the same capability that can
3028 + // open the media optimization page is enough, but a nonce alone
3029 + // never was.
3030 + if (!current_user_can('upload_files')) {
3031 + wp_send_json_error(__('Permission denied.', 'metasync'));
3032 + }
3033 +
2906 3034 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-batch-optimizer.php';
2907 3035 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
2908 3036
2909 3037 $progress = Metasync_Media_Batch_Optimizer::get_progress();
@@ -2934,10 +3062,19 @@
2934 3062 $settings = Metasync_Media_Settings::get_settings();
2935 3063
2936 3064 $success = 0;
2937 3065 $failed = 0;
3066 + $denied = 0;
2938 3067
2939 3068 foreach ($ids as $id) {
3069 + // Per-attachment ownership: replace mode deletes originals, so
3070 + // upload_files alone must not allow converting images the caller
3071 + // does not own/cannot edit.
3072 + if (!current_user_can('edit_post', $id)) {
3073 + $denied++;
3074 + continue;
3075 + }
3076 +
2940 3077 if (Metasync_Image_Converter::convert_attachment($id, $settings)) {
2941 3078 $success++;
2942 3079 } else {
2943 3080 $failed++;
@@ -2946,8 +3083,9 @@
2946 3083
2947 3084 wp_send_json_success([
2948 3085 'success' => $success,
2949 3086 'failed' => $failed,
3087 + 'denied' => $denied,
2950 3088 ]);
2951 3089 }
2952 3090
2953 3091 /**
@@ -2970,11 +3108,19 @@
2970 3108
2971 3109 $success = 0;
2972 3110 $failed = 0;
2973 3111 $skipped = 0;
3112 + $denied = 0;
2974 3113 $errors = [];
2975 3114
2976 3115 foreach ($ids as $id) {
3116 + // Per-attachment ownership: reverting deletes the attachment's
3117 + // converted files, so restrict it per image, not per role.
3118 + if (!current_user_can('edit_post', $id)) {
3119 + $denied++;
3120 + continue;
3121 + }
3122 +
2977 3123 $format = get_post_meta($id, '_metasync_converted_format', true);
2978 3124
2979 3125 if (!$format) {
2980 3126 $skipped++;
@@ -2984,8 +3130,9 @@
2984 3130 if (!Metasync_Image_Converter::can_revert($id)) {
2985 3131 $skipped++;
2986 3132 $file = get_attached_file($id);
2987 3133 $name = $file ? basename($file) : "ID {$id}";
3134 + /* translators: %s: attachment file name. */
2988 3135 $errors[] = sprintf(__('%s: skipped — original image unavailable.', 'metasync'), $name);
2989 3136 continue;
2990 3137 }
2991 3138
@@ -2994,8 +3141,9 @@
2994 3141 } else {
2995 3142 $failed++;
2996 3143 $file = get_attached_file($id);
2997 3144 $name = $file ? basename($file) : "ID {$id}";
3145 + /* translators: %s: attachment file name. */
2998 3146 $errors[] = sprintf(__('%s: revert failed (original file may not exist).', 'metasync'), $name);
2999 3147 }
3000 3148 }
3001 3149
@@ -3002,8 +3150,9 @@
3002 3150 wp_send_json_success([
3003 3151 'success' => $success,
3004 3152 'failed' => $failed,
3005 3153 'skipped' => $skipped,
3154 + 'denied' => $denied,
3006 3155 'errors' => $errors,
3007 3156 ]);
3008 3157 }
3009 3158
@@ -3079,8 +3228,86 @@
3079 3228 Metasync_Media_Batch_Optimizer::process_batch_tick();
3080 3229 }
3081 3230
3082 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 + /**
3083 3310 * Report Issue page callback
3084 3311 */
3085 3312 public function create_admin_report_issue_page()
3086 3313 {
@@ -3092,8 +3319,17 @@
3092 3319 * XML Sitemap page callback
3093 3320 */
3094 3321 public function create_admin_xml_sitemap_page()
3095 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 +
3096 3332 // Load sitemap generator class
3097 3333 require_once plugin_dir_path(dirname(__FILE__)) . 'sitemap/class-metasync-sitemap-generator.php';
3098 3334
3099 3335 $sitemap_generator = new Metasync_Sitemap_Generator();
@@ -3124,10 +3360,13 @@
3124 3360 // Regenerate sitemap with new content settings
3125 3361 $result = $sitemap_generator->generate_sitemap();
3126 3362 if (is_wp_error($result)) {
3127 3363 echo '<div class="notice notice-error"><p>' . esc_html(
3364 + /* translators: %s: error message. */
3128 3365 sprintf(__('Settings saved but sitemap generation failed: %s', 'metasync'), $result->get_error_message())
3129 3366 ) . '</p></div>';
3367 + } elseif (false === $result) {
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>';
3130 3369 } else {
3131 3370 echo '<div class="notice notice-success"><p>' . esc_html__('Sitemap content settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3132 3371 }
3133 3372 }
@@ -3138,10 +3377,10 @@
3138 3377
3139 3378 // Build generic taxonomy filters
3140 3379 $news_taxonomies = [];
3141 3380 if (!empty($_POST['news_taxonomies']) && is_array($_POST['news_taxonomies'])) {
3142 - foreach ($_POST['news_taxonomies'] as $tax_name => $term_ids) {
3143 - $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));
3144 3383 }
3145 3384 }
3146 3385
3147 3386 $news_settings = [
@@ -3166,9 +3405,9 @@
3166 3405 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3167 3406 } else {
3168 3407 // Also remove physical file if it exists
3169 3408 if (file_exists(ABSPATH . 'news-sitemap.xml')) {
3170 - @unlink(ABSPATH . 'news-sitemap.xml');
3409 + @wp_delete_file(ABSPATH . 'news-sitemap.xml');
3171 3410 }
3172 3411 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
3173 3412 }
3174 3413 }
@@ -3179,10 +3418,10 @@
3179 3418
3180 3419 // Build generic taxonomy filters
3181 3420 $video_taxonomies = [];
3182 3421 if (!empty($_POST['video_taxonomies']) && is_array($_POST['video_taxonomies'])) {
3183 - foreach ($_POST['video_taxonomies'] as $tax_name => $term_ids) {
3184 - $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));
3185 3424 }
3186 3425 }
3187 3426
3188 3427 $video_settings = [
@@ -3190,8 +3429,12 @@
3190 3429 'post_types' => array_map('sanitize_key', (array) ($_POST['video_post_types'] ?? ['post', 'page'])),
3191 3430 'auto_detect' => isset($_POST['auto_detect']),
3192 3431 'taxonomies' => $video_taxonomies,
3193 3432 'excluded_urls' => sanitize_textarea_field(wp_unslash($_POST['video_excluded_urls'] ?? '')),
3433 + // This array replaces the stored option wholesale, so every
3434 + // setting must be represented here or it is wiped on save.
3435 + 'video_url_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_url_meta_keys'] ?? '')),
3436 + 'video_thumbnail_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_thumbnail_meta_keys'] ?? '')),
3194 3437 ];
3195 3438
3196 3439 // Always invalidate old cache before saving new settings
3197 3440 delete_transient('metasync_vsm_' . md5('video-sitemap.xml'));
@@ -3204,9 +3447,9 @@
3204 3447 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3205 3448 } else {
3206 3449 // Also remove physical file if it exists
3207 3450 if (file_exists(ABSPATH . 'video-sitemap.xml')) {
3208 - @unlink(ABSPATH . 'video-sitemap.xml');
3451 + @wp_delete_file(ABSPATH . 'video-sitemap.xml');
3209 3452 }
3210 3453 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
3211 3454 }
3212 3455 }
@@ -3249,8 +3492,17 @@
3249 3492 if ($sitemap_generator->generate_news_sitemap()) {
3250 3493 $extras[] = 'news';
3251 3494 }
3252 3495 }
3496 +
3497 + // Reclaim unreferenced memory between the news and video passes
3498 + // so the video generator starts with more headroom (they share
3499 + // one request). Each generator already releases its own object
3500 + // cache via clean_post_cache(), so no cache flush is needed here.
3501 + if (function_exists('gc_collect_cycles')) {
3502 + gc_collect_cycles();
3503 + }
3504 +
3253 3505 if (!empty($video_opts['enabled'])) {
3254 3506 if ($sitemap_generator->generate_video_sitemap()) {
3255 3507 $extras[] = 'video';
3256 3508 }
@@ -3255,21 +3507,30 @@
3255 3507 $extras[] = 'video';
3256 3508 }
3257 3509 }
3258 3510
3511 + // Same inter-pass GC before the main sitemap pass begins.
3512 + if (function_exists('gc_collect_cycles')) {
3513 + gc_collect_cycles();
3514 + }
3515 +
3259 3516 // Generate main sitemap (its index will include news/video since they now exist)
3260 3517 $result = $sitemap_generator->generate_sitemap();
3261 3518
3262 3519 if (is_wp_error($result)) {
3263 3520 $error_msg = $result->get_error_message();
3264 - 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
3265 3522 echo '<div class="notice notice-error"><p>' . esc_html(
3523 + /* translators: %s: error message. */
3266 3524 sprintf(__('Sitemap generation failed: %s', 'metasync'), $error_msg)
3267 3525 ) . '</p></div>';
3526 + } elseif (false === $result) {
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>';
3268 3528 } else {
3269 3529 $message = esc_html__('Sitemap generated successfully!', 'metasync');
3270 3530 if (!empty($extras)) {
3271 3531 $message .= ' ' . sprintf(
3532 + /* translators: %s: list of sitemap types. */
3272 3533 esc_html__('Also generated %s sitemap(s).', 'metasync'),
3273 3534 implode(' & ', $extras)
3274 3535 );
3275 3536 }
@@ -3304,9 +3565,9 @@
3304 3565
3305 3566 if ($deleted) {
3306 3567 // Disable auto-update only when the general sitemap is removed
3307 3568 update_option('metasync_sitemap_auto_update', false);
3308 - // Re-enable WP core sitemap only if no other MetaSync sitemaps remain (WP-396)
3569 + // Re-enable WP core sitemap only if no other MetaSync sitemaps remain
3309 3570 if (!$sitemap_generator->sitemap_exists()) {
3310 3571 delete_option('metasync_disable_wp_sitemap');
3311 3572 }
3312 3573 echo '<div class="notice notice-success"><p>' . esc_html__('General sitemap deleted successfully!', 'metasync') . '</p></div>';
@@ -3335,9 +3596,9 @@
3335 3596
3336 3597 if ($deleted) {
3337 3598 // Also disable auto-update when deleting
3338 3599 update_option('metasync_sitemap_auto_update', false);
3339 - // Re-enable WP core sitemap so the site isn't left with zero sitemaps (WP-396)
3600 + // Re-enable WP core sitemap so the site isn't left with zero sitemaps
3340 3601 delete_option('metasync_disable_wp_sitemap');
3341 3602 echo '<div class="notice notice-success"><p>' . esc_html__('All sitemaps deleted successfully!', 'metasync') . '</p></div>';
3342 3603 } else {
3343 3604 echo '<div class="notice notice-error"><p>' . esc_html__('Failed to delete sitemaps. The files may not exist or are not writable.', 'metasync') . '</p></div>';
@@ -3458,47 +3719,8 @@
3458 3719 Metasync_Admin_Pages::get_instance($this)->create_admin_breadcrumbs_page();
3459 3720 }
3460 3721
3461 3722 /**
3462 - * Google Instant Index Setting page callback
3463 - */
3464 - public function create_admin_google_instant_index_page()
3465 - {
3466 - $this->render_layout_open('Instant Indexing', 'instant_index', 'Submit URLs to Google for instant indexing via the Indexing API.');
3467 -
3468 - // Render shared Google Index credentials section
3469 - if (!function_exists('google_index_direct')) {
3470 - if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) {
3471 - require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
3472 - } else {
3473 - error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php');
3474 - return;
3475 - }
3476 - }
3477 - $google_index = google_index_direct();
3478 - $service_info = $google_index->get_service_account_info();
3479 - $is_configured = !isset($service_info['error']);
3480 -
3481 - $saved_json_display = $is_configured ? $google_index->get_redacted_config_json() : '';
3482 -
3483 - include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-index-api-settings.php';
3484 -
3485 - // Render post types selection with save form
3486 - $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
3487 - $post_types_settings = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
3488 - ?>
3489 - <form method="POST" action="">
3490 - <?php include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-instant-post-types.php'; ?>
3491 - <div class="dashboard-card" style="padding: 20px;">
3492 - <?php submit_button('Save Post Types', 'primary', 'submit', false, array('class' => 'button button-primary')); ?>
3493 - </div>
3494 - </form>
3495 - <?php
3496 -
3497 - $this->render_layout_close();
3498 - }
3499 -
3500 - /**
3501 3723 * Google Console page callback
3502 3724 */
3503 3725 public function create_admin_google_console_page()
3504 3726 {
@@ -3521,16 +3743,8 @@
3521 3743 $this->render_layout_close();
3522 3744 }
3523 3745
3524 3746 /**
3525 - * General Options page callback
3526 - */
3527 - public function create_admin_optimal_settings_page()
3528 - {
3529 - Metasync_Admin_Pages::get_instance($this)->create_admin_optimal_settings_page();
3530 - }
3531 -
3532 - /**
3533 3747 * Global Options page callback
3534 3748 */
3535 3749 public function create_admin_global_settings_page()
3536 3750 {
@@ -3562,16 +3776,8 @@
3562 3776 Metasync_Admin_Pages::get_instance($this)->create_admin_seo_controls_page();
3563 3777 }
3564 3778
3565 3779 /**
3566 - * Site Optimal Settings page callback
3567 - */
3568 - public function optimization_settings_options()
3569 - {
3570 - Metasync_Admin_Pages::get_instance($this)->optimization_settings_options();
3571 - }
3572 -
3573 - /**
3574 3780 * redirection page callback with tabs
3575 3781 */
3576 3782 public function create_admin_redirections_page()
3577 3783 {
@@ -3620,8 +3826,9 @@
3620 3826 if (!get_transient('metasync_llms_conflict')) {
3621 3827 return;
3622 3828 }
3623 3829 echo '<div class="notice notice-info is-dismissible"><p>';
3830 + /* translators: %s: effective plugin name (whitelabel-aware). */
3624 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()));
3625 3832 echo '</p></div>';
3626 3833 }
3627 3834
@@ -3691,8 +3898,17 @@
3691 3898 public function ajax_restore_robots_backup()
3692 3899 {
3693 3900 Metasync_Admin_Ajax::instance()->ajax_restore_robots_backup();
3694 3901 }
3902 +
3903 + /**
3904 + * AJAX handler to fetch a paginated page of robots.txt backups
3905 + */
3906 + public function ajax_get_robots_backups()
3907 + {
3908 + Metasync_Admin_Ajax::instance()->ajax_get_robots_backups();
3909 + }
3910 +
3695 3911 public function ajax_create_redirect_from_404()
3696 3912 {
3697 3913 Metasync_Admin_Ajax::instance()->ajax_create_redirect_from_404();
3698 3914 }
@@ -3804,17 +4020,17 @@
3804 4020 return;
3805 4021 }
3806 4022
3807 4023 // Get pagination parameters
3808 - $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
3809 - $per_page = 10;
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
4025 + $per_page = Metasync_Per_Page_Helper::resolve('sync_log', 10);
3810 4026 $offset = ($page - 1) * $per_page;
3811 4027
3812 4028 // Get filters
3813 4029 $filters = [
3814 4030 // UI exposes date_range and status only. We compute date_from/date_to based on date_range
3815 - 'date_range' => isset($_GET['date_range']) ? sanitize_text_field(wp_unslash($_GET['date_range'])) : '',
3816 - '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
3817 4033 ];
3818 4034
3819 4035 // Map date_range to concrete date_from/date_to for DB queries
3820 4036 $date_range = $filters['date_range'];
@@ -3823,28 +4039,28 @@
3823 4039 $date_to = '';
3824 4040
3825 4041 if (!empty($date_range)) {
3826 4042 // End boundary is now by default
3827 - $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);
3828 4044
3829 4045 if ($date_range === 'today') {
3830 4046 $start_ts = strtotime('today', $wp_now_ts);
3831 - $date_from = date('Y-m-d H:i:s', $start_ts);
4047 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3832 4048 } elseif ($date_range === 'yesterday') {
3833 4049 $start_ts = strtotime('yesterday', $wp_now_ts);
3834 4050 $end_ts = strtotime('today', $wp_now_ts) - 1; // end of yesterday
3835 - $date_from = date('Y-m-d H:i:s', $start_ts);
3836 - $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);
3837 4053 } elseif ($date_range === 'this_week') {
3838 4054 $start_of_week = (int) get_option('start_of_week', 1); // 0=Sun, 1=Mon
3839 - $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
3840 4056 // Convert start_of_week to PHP's 0..6 where 0=Sunday
3841 4057 $delta_days = ($day_of_week - $start_of_week + 7) % 7;
3842 4058 $start_ts = strtotime('-' . $delta_days . ' days', strtotime('today', $wp_now_ts));
3843 - $date_from = date('Y-m-d H:i:s', $start_ts);
4059 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3844 4060 } elseif ($date_range === 'this_month') {
3845 - $start_ts = strtotime(date('Y-m-01 00:00:00', $wp_now_ts));
3846 - $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);
3847 4063 } elseif ($date_range === 'all') {
3848 4064 // no bounds
3849 4065 }
3850 4066 }
@@ -3871,12 +4087,24 @@
3871 4087 ?>
3872 4088 <div class="dashboard-card">
3873 4089 <div class="sync-log-header">
3874 4090 <div class="sync-log-title-section">
3875 - <h2>Changes Log</h2>
3876 - <p style="color: var(--dashboard-text-secondary); margin-bottom: 0;">
3877 - Recent content synchronizations from external tools.
3878 - <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>
3879 4107 </p>
3880 4108 </div>
3881 4109
3882 4110 <!-- Filters + Clear Log button - Right aligned -->
@@ -3886,9 +4114,9 @@
3886 4114 data-nonce="<?php echo esc_attr(wp_create_nonce('metasync_clear_sync_log')); ?>">
3887 4115 🗑 Clear Log
3888 4116 </button>
3889 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;">
3890 - <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 ?>">
3891 4119
3892 4120 <select name="date_range" class="sync-filter-select">
3893 4121 <option value="all" <?php selected($filters['date_range'] ?? 'all', 'all'); ?>> All Time</option>
3894 4122 <option value="today" <?php selected($filters['date_range'] ?? '', 'today'); ?>>Today</option>
@@ -3910,9 +4138,9 @@
3910 4138 <div class="sync-log-list">
3911 4139 <?php if (empty($sync_records)): ?>
3912 4140 <div class="sync-log-empty">
3913 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>
3914 - <h3>No sync records found</h3>
4142 + <h2>No sync records found</h2>
3915 4143 <p>Sync records will appear here when content/pages receive new updates.</p>
3916 4144 </div>
3917 4145 <?php else: ?>
3918 4146 <?php foreach ($sync_records as $record): ?>
@@ -3966,30 +4194,32 @@
3966 4194 <?php endif; ?>
3967 4195 </div>
3968 4196
3969 4197 <!-- Pagination -->
3970 - <?php if ($total_pages > 1): ?>
3971 - <div class="sync-log-pagination">
3972 - <div class="sync-log-pagination-info">
3973 - Total records: <?php echo intval( $total_records ); ?> | Showing <?php echo intval( $offset ) + 1; ?>-<?php echo intval( min($offset + $per_page, $total_records) ); ?>
3974 - </div>
4198 + <div class="sync-log-pagination">
4199 + <div class="sync-log-pagination-info">
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; ?>
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)); ?>
4203 + </div>
3975 4204
4205 + <?php if ($total_pages > 1): ?>
3976 4206 <div class="sync-log-pagination-controls">
3977 4207 <?php if ($page > 1): ?>
3978 - <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>
3979 4209 <?php endif; ?>
3980 4210
3981 4211 <?php for ($i = max(1, $page - 2); $i <= min($total_pages, $page + 2); $i++): ?>
3982 - <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) ); ?>"
3983 4213 class="sync-pagination-btn <?php echo $i === $page ? 'active' : ''; ?>"><?php echo intval( $i ); ?></a>
3984 4214 <?php endfor; ?>
3985 4215
3986 4216 <?php if ($page < $total_pages): ?>
3987 - <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>
3988 4218 <?php endif; ?>
3989 4219 </div>
3990 - </div>
3991 - <?php endif; ?>
4220 + <?php endif; ?>
4221 + </div>
3992 4222 </div>
3993 4223 <?php $this->render_layout_close(); ?>
3994 4224
3995 4225 <script>
@@ -4070,8 +4300,19 @@
4070 4300 if (!empty($value)) {
4071 4301 $query_parts[] = $key . '=' . urlencode($value);
4072 4302 }
4073 4303 }
4304 +
4305 + // Preserve the user-selected results-per-page value across page
4306 + // navigation so a non-default page size survives clicking a page link.
4307 + $per_page_key = Metasync_Per_Page_Helper::request_key('sync_log');
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
4310 + if (in_array($per_page, Metasync_Per_Page_Helper::allowed_values(), true)) {
4311 + $query_parts[] = $per_page_key . '=' . $per_page;
4312 + }
4313 + }
4314 +
4074 4315 return !empty($query_parts) ? '&' . implode('&', $query_parts) : '';
4075 4316 }
4076 4317
4077 4318 /**
@@ -4245,18 +4486,8 @@
4245 4486 {
4246 4487 Metasync_Connect_Manager::instance()->handle_session_management_early();
4247 4488 }
4248 4489
4249 - /**
4250 - * @deprecated 2.5.12 Use Metasync_Auth_Manager instead of sessions for authentication
4251 - */
4252 - private function safe_session_start() {
4253 - // This method is deprecated and no longer used
4254 - // Authentication now uses Metasync_Auth_Manager with WordPress transients and user meta
4255 - _deprecated_function(__METHOD__, '2.5.12', 'Metasync_Auth_Manager');
4256 - return Metasync_Session_Helper::safe_start();
4257 - }
4258 -
4259 4490 private function handle_whitelabel_session_logic()
4260 4491 {
4261 4492 Metasync_Connect_Manager::instance()->handle_whitelabel_session_logic();
4262 4493 }
@@ -4330,8 +4561,9 @@
4330 4561 <!-- Per-Core Load Threshold -->
4331 4562 <div style="margin-bottom: 24px;">
4332 4563 <label for="cpu_load_per_core_threshold" style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4333 4564 Per-Core Load Threshold
4565 + <?php Metasync::render_tooltip_icon('cpu_per_core_load_threshold', 'How busy each CPU core can get before the plugin pauses background SEO syncing. Lower = more cautious. Most sites leave this at 2.0.'); ?>
4334 4566 </label>
4335 4567 <input type="number"
4336 4568 id="cpu_load_per_core_threshold"
4337 4569 name="metasync_options[performance][cpu_load_per_core_threshold]"
@@ -4351,9 +4583,9 @@
4351 4583 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4352 4584 Effective Threshold
4353 4585 </label>
4354 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);">
4355 - <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>
4356 4588 </div>
4357 4589 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
4358 4590 Calculated as: cores × per-core threshold
4359 4591 </p>
@@ -4371,13 +4603,13 @@
4371 4603 <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo intval($stats['deferrals']); ?></div>
4372 4604 </div>
4373 4605 <div>
4374 4606 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Max Load Observed</div>
4375 - <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>
4376 4608 </div>
4377 4609 <div>
4378 4610 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Average Load</div>
4379 - <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>
4380 4612 </div>
4381 4613 </div>
4382 4614 </div>
4383 4615
@@ -4883,88 +5115,8 @@
4883 5115
4884 5116 /**
4885 5117 * Get the settings option array and print one of its values
4886 5118 */
4887 - public function no_index_posts_callback()
4888 - {
4889 - Metasync_Settings_Fields::instance()->no_index_posts_callback();
4890 - }
4891 -
4892 - /**
4893 - * Get the settings option array and print one of its values
4894 - */
4895 - public function no_follow_links_callback()
4896 - {
4897 - Metasync_Settings_Fields::instance()->no_follow_links_callback();
4898 - }
4899 -
4900 - /**
4901 - * Get the settings option array and print one of its values
4902 - */
4903 - public function open_external_links_callback()
4904 - {
4905 - Metasync_Settings_Fields::instance()->open_external_links_callback();
4906 - }
4907 -
4908 - /**
4909 - * Get the settings option array and print one of its values
4910 - */
4911 - public function add_alt_image_tags_callback()
4912 - {
4913 - Metasync_Settings_Fields::instance()->add_alt_image_tags_callback();
4914 - }
4915 -
4916 - /**
4917 - * Get the settings option array and print one of its values
4918 - */
4919 - public function add_title_image_tags_callback()
4920 - {
4921 - Metasync_Settings_Fields::instance()->add_title_image_tags_callback();
4922 - }
4923 -
4924 - /**
4925 - * Get the settings option array and print one of its values
4926 - */
4927 - public function site_type_callback()
4928 - {
4929 - Metasync_Settings_Fields::instance()->site_type_callback();
4930 - }
4931 -
4932 - /**
4933 - * Get the settings option array and print one of its values
4934 - */
4935 - public function site_business_type_callback()
4936 - {
4937 - Metasync_Settings_Fields::instance()->site_business_type_callback();
4938 - }
4939 -
4940 - /**
4941 - * Get the settings option array and print one of its values
4942 - */
4943 - public function site_company_name_callback()
4944 - {
4945 - Metasync_Settings_Fields::instance()->site_company_name_callback();
4946 - }
4947 -
4948 - /**
4949 - * Get the settings option array and print one of its values
4950 - */
4951 - public function site_google_logo_callback()
4952 - {
4953 - Metasync_Settings_Fields::instance()->site_google_logo_callback();
4954 - }
4955 -
4956 - /**
4957 - * Get the settings option array and print one of its values
4958 - */
4959 - public function site_social_share_image_callback()
4960 - {
4961 - Metasync_Settings_Fields::instance()->site_social_share_image_callback();
4962 - }
4963 -
4964 - /**
4965 - * Get the settings option array and print one of its values
4966 - */
4967 5119 public function common_robot_meta_tags_callback()
4968 5120 {
4969 5121 Metasync_Settings_Fields::instance()->common_robot_meta_tags_callback();
4970 5122 }
@@ -5162,8 +5314,14 @@
5162 5314 * Show a one-time admin notice when a page builder is detected but the
5163 5315 * "Default Page Builder" setting has never been explicitly saved.
5164 5316 */
5165 5317 public function display_page_builder_notice() {
5318 + // Only relevant on the plugin's own settings page — avoid repeating
5319 + // this notice across every admin screen.
5320 + if (!isset($_GET['page']) || wp_unslash($_GET['page']) !== self::$page_slug) {
5321 + return;
5322 + }
5323 +
5166 5324 $configured = Metasync::get_option('general')['default_page_builder'] ?? '';
5167 5325
5168 5326 // Setting already saved — nothing to warn about
5169 5327 if (!empty($configured)) {
@@ -5176,9 +5334,9 @@
5176 5334 return;
5177 5335 }
5178 5336
5179 5337 // Handle dismiss action
5180 - 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')) {
5181 5339 update_user_meta(get_current_user_id(), 'metasync_builder_notice_dismissed', '1');
5182 5340 return;
5183 5341 }
5184 5342
@@ -5273,9 +5431,10 @@
5273 5431 * Saves the CPU load threshold and returns statistics
5274 5432 */
5275 5433 public function ajax_save_performance_settings() {
5276 5434 # Check nonce for security and return early if invalid
5277 - 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')) {
5278 5437 wp_send_json_error(array('message' => 'Invalid nonce'));
5279 5438 return;
5280 5439 }
5281 5440
@@ -5343,9 +5502,9 @@
5343 5502 if (!wp_next_scheduled('metasync_cleanup_transients')) {
5344 5503 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_cleanup_transients');
5345 5504
5346 5505 if (!$scheduled) {
5347 - 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
5348 5507 }
5349 5508 }
5350 5509 }
5351 5510
@@ -5356,9 +5515,9 @@
5356 5515 {
5357 5516 $timestamp = wp_next_scheduled('metasync_cleanup_transients');
5358 5517 if ($timestamp) {
5359 5518 wp_unschedule_event($timestamp, 'metasync_cleanup_transients');
5360 - 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
5361 5520 }
5362 5521 }
5363 5522
5364 5523 /**
@@ -5380,12 +5539,10 @@
5380 5539 {
5381 5540 if (!wp_next_scheduled('metasync_hidden_post_check')) {
5382 5541 $scheduled = wp_schedule_event(time(), 'metasync_weekly', 'metasync_hidden_post_check');
5383 5542
5384 - if ($scheduled) {
5385 - error_log('MetaSync: Hidden post manager cron job scheduled successfully (runs every 7 days)');
5386 - } else {
5387 - 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
5388 5545 }
5389 5546 }
5390 5547 }
5391 5548
@@ -5396,12 +5553,10 @@
5396 5553 public function maybe_schedule_otto_recheck_404_cron()
5397 5554 {
5398 5555 if (!wp_next_scheduled('metasync_otto_recheck_404_exclusions')) {
5399 5556 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_otto_recheck_404_exclusions');
5400 - if ($scheduled) {
5401 - error_log('MetaSync: OTTO 404 recheck cron job scheduled successfully (runs daily)');
5402 - } else {
5403 - 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
5404 5559 }
5405 5560 }
5406 5561 }
5407 5562
@@ -5474,13 +5629,13 @@
5474 5629
5475 5630 try {
5476 5631 // 1. Post revisions
5477 5632 if (!empty($settings['clean_post_revisions'])) {
5478 - $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
5479 5634 "DELETE FROM {$wpdb->posts} WHERE post_type = 'revision'"
5480 5635 );
5481 5636 // Remove postmeta left behind by deleted revisions
5482 - $wpdb->query(
5637 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5483 5638 "DELETE pm FROM {$wpdb->postmeta} pm
5484 5639 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5485 5640 WHERE p.ID IS NULL"
5486 5641 );
@@ -5488,20 +5643,19 @@
5488 5643
5489 5644 // 2. Trashed posts + their postmeta
5490 5645 if (!empty($settings['clean_trashed_posts'])) {
5491 5646 // Collect IDs first to cleanly remove postmeta
5492 - $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
5493 5648 "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'trash'"
5494 5649 );
5495 5650 if (!empty($trashed_ids)) {
5496 - $placeholders = implode(',', array_fill(0, count($trashed_ids), '%d'));
5497 - $wpdb->query(
5651 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5498 5652 $wpdb->prepare(
5499 - "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')) . ')',
5500 5654 $trashed_ids
5501 5655 )
5502 5656 );
5503 - $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
5504 5658 "DELETE FROM {$wpdb->posts} WHERE post_status = 'trash'"
5505 5659 );
5506 5660 } else {
5507 5661 $stats['trashed_posts'] = 0;
@@ -5509,9 +5663,9 @@
5509 5663 }
5510 5664
5511 5665 // 3. Trashed comments
5512 5666 if (!empty($settings['clean_trashed_comments'])) {
5513 - $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
5514 5668 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'trash'"
5515 5669 );
5516 5670 }
5517 5671
@@ -5516,9 +5670,9 @@
5516 5670 }
5517 5671
5518 5672 // 4. Spam comments
5519 5673 if (!empty($settings['clean_spam_comments'])) {
5520 - $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
5521 5675 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'spam'"
5522 5676 );
5523 5677 }
5524 5678
@@ -5524,15 +5678,15 @@
5524 5678
5525 5679 // 5. Expired transients — direct SQL, no cache flush
5526 5680 if (!empty($settings['clean_expired_transients'])) {
5527 5681 // Delete timeout rows that have already expired
5528 - $wpdb->query(
5682 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5529 5683 "DELETE FROM {$wpdb->options}
5530 5684 WHERE option_name LIKE '\_transient\_timeout\_%'
5531 5685 AND option_value + 0 < UNIX_TIMESTAMP()"
5532 5686 );
5533 5687 // Delete value rows whose timeout row no longer exists
5534 - $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
5535 5689 "DELETE o FROM {$wpdb->options} o
5536 5690 LEFT JOIN {$wpdb->options} t
5537 5691 ON t.option_name = CONCAT('_transient_timeout_', SUBSTRING(o.option_name, 12))
5538 5692 WHERE o.option_name LIKE '\_transient\_%'
@@ -5542,9 +5696,9 @@
5542 5696 }
5543 5697
5544 5698 // 6. Orphaned postmeta (post_id references a post that no longer exists)
5545 5699 if (!empty($settings['clean_orphaned_postmeta'])) {
5546 - $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
5547 5701 "DELETE pm FROM {$wpdb->postmeta} pm
5548 5702 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5549 5703 WHERE p.ID IS NULL"
5550 5704 );
@@ -5556,12 +5710,10 @@
5556 5710 $settings['last_run_at'] = time();
5557 5711 $settings['last_run_stats'] = $stats;
5558 5712 update_option('metasync_db_cleanup_settings', $settings);
5559 5713
5560 - error_log('MetaSync: DB cleanup completed — ' . json_encode($stats));
5561 -
5562 5714 } catch (Exception $e) {
5563 - 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
5564 5716 }
5565 5717 }
5566 5718
5567 5719 /**
@@ -5778,10 +5930,11 @@
5778 5930 /**
5779 5931 * AJAX: Save DB cleanup settings and reschedule cron accordingly.
5780 5932 */
5781 5933 public function ajax_save_db_cleanup_settings() {
5934 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5782 5935 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5783 - !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')) {
5784 5937 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
5785 5938 return;
5786 5939 }
5787 5940
@@ -5838,10 +5991,11 @@
5838 5991 * AJAX: Manually trigger the DB cleanup and return stats for the UI.
5839 5992 * Persists current form state first so unsaved checkbox changes are respected.
5840 5993 */
5841 5994 public function ajax_run_db_cleanup() {
5995 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5842 5996 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5843 - !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')) {
5844 5998 wp_send_json_error(array('message' => 'Invalid security token.'));
5845 5999 return;
5846 6000 }
5847 6001
@@ -5917,35 +6071,8 @@
5917 6071 ));
5918 6072 }
5919 6073
5920 6074 /**
5921 - * Control plugin auto-updates based on user setting
5922 - *
5923 - * @param bool $update Whether to update
5924 - * @param object $item Update offer
5925 - * @return bool Whether to allow auto-update
5926 - */
5927 - public function control_plugin_auto_updates($update, $item)
5928 - {
5929 - # Check if the item object has the slug property
5930 - if (!isset($item->slug)) {
5931 - return $update;
5932 - }
5933 -
5934 - // Check if this is our plugin
5935 - if ($item->slug === 'metasync') {
5936 - $general_settings = Metasync::get_option('general') ?? [];
5937 - $enable_auto_updates = $general_settings['enable_auto_updates'] ?? false;
5938 -
5939 - // Return the user's preference (true = allow auto-updates, false = prevent)
5940 - return $enable_auto_updates === 'true' || $enable_auto_updates === true;
5941 - }
5942 -
5943 - // For other plugins, don't interfere with their auto-update settings
5944 - return $update;
5945 - }
5946 -
5947 - /**
5948 6075 * AJAX handler to add excluded URL for OTTO
5949 6076 */
5950 6077 public function ajax_otto_add_excluded_url()
5951 6078 {
@@ -6091,8 +6218,9 @@
6091 6218 // If page has raw HTML or custom CSS from conversion, show badge
6092 6219 if ($has_raw_html || !empty($has_custom_css)) {
6093 6220 $label = $this->get_html_source_label();
6094 6221 $tooltip = sprintf(
6222 + /* translators: %s: HTML source label. */
6095 6223 __('This page was created using %s HTML-to-Builder converter', 'metasync'),
6096 6224 $label
6097 6225 );
6098 6226
@@ -6134,9 +6262,9 @@
6134 6262
6135 6263 // Don't show the "HTML-to-Builder converter" banner on LPS-synced / custom-HTML
6136 6264 // pages: those are raw-HTML store-and-serve pages, NOT actually converted to a
6137 6265 // page builder, so the banner mislabels them. It still shows for pages genuinely
6138 - // produced by the converter. (WP-486)
6266 + // produced by the converter.
6139 6267 if (function_exists('metasync_is_custom_or_lps_page') && metasync_is_custom_or_lps_page($post->ID)) {
6140 6268 return;
6141 6269 }
6142 6270
@@ -6145,8 +6273,9 @@
6145 6273
6146 6274 if ($has_raw_html || !empty($has_custom_css)) {
6147 6275 $label = $this->get_html_source_label();
6148 6276 $message = sprintf(
6277 + /* translators: %s: HTML source label. */
6149 6278 __('This page was created using %s HTML-to-Builder converter. The design is preserved with custom CSS and inline styles.', 'metasync'),
6150 6279 '<strong>' . esc_html($label) . '</strong>'
6151 6280 );
6152 6281
@@ -6160,9 +6289,9 @@
6160 6289 <p class="metasync-editor-message">%s</p>
6161 6290 </div>
6162 6291 </div>',
6163 6292 esc_html($label),
6164 - $message
6293 + wp_kses($message, array('strong' => array()))
6165 6294 );
6166 6295 }
6167 6296 }
6168 6297
@@ -6185,9 +6314,9 @@
6185 6314 ?>
6186 6315 <fieldset class="inline-edit-col-left metasync-quick-edit-source">
6187 6316 <div class="inline-edit-col">
6188 6317 <label>
6189 - <span class="title"><?php _e('Source', 'metasync'); ?></span>
6318 + <span class="title"><?php esc_html_e('Source', 'metasync'); ?></span>
6190 6319 <span class="metasync-quick-edit-badge-container"></span>
6191 6320 </label>
6192 6321 </div>
6193 6322 </fieldset>
@@ -6199,8 +6328,9 @@
6199 6328 */
6200 6329 public function add_html_pages_dashboard_widget()
6201 6330 {
6202 6331 $label = $this->get_html_source_label();
6332 + /* translators: %s: HTML source label. */
6203 6333 $widget_title = sprintf(__('%s Pages', 'metasync'), $label);
6204 6334
6205 6335 wp_add_dashboard_widget(
6206 6336 'metasync_html_pages_widget',
@@ -6353,8 +6483,18 @@
6353 6483 if (!isset($_POST['submit'])) {
6354 6484 return;
6355 6485 }
6356 6486
6487 + // This handler runs on admin_init, which also fires inside
6488 + // admin-ajax.php before its login gate, so a bare isset() check let
6489 + // any request (including logged-out ones) rewrite the auto-submit
6490 + // post types. Require the nonce and plugin access before writing.
6491 + if (!isset($_POST['metasync_instant_indexing_nonce'])
6492 + || !wp_verify_nonce(sanitize_key(wp_unslash($_POST['metasync_instant_indexing_nonce'])), 'metasync_instant_indexing_settings')
6493 + || !Metasync::current_user_has_plugin_access()) {
6494 + return;
6495 + }
6496 +
6357 6497 // Save post types for Google Instant Indexing auto-submit
6358 6498 if (isset($_POST['metasync_post_types'])) {
6359 6499 $post_data = metasync_sanitize_input_array($_POST);
6360 6500 $post_types = is_array($post_data['metasync_post_types']) ? array_map('sanitize_title', $post_data['metasync_post_types']) : [];
@@ -6438,12 +6578,24 @@
6438 6578 if (!empty($seo_controls['enable_googleinstantindex']) && $seo_controls['enable_googleinstantindex'] === 'true') {
6439 6579 $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
6440 6580 $post_types = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
6441 6581
6442 - if (in_array($post->post_type, $post_types) && function_exists('google_index_direct')) {
6443 - $service_info = google_index_direct()->get_service_account_info();
6444 - if (!isset($service_info['error'])) {
6445 - google_index_direct()->index_post($post_id, $post->post_type, 'update');
6582 + if (in_array($post->post_type, $post_types)) {
6583 + // save_post is the single owner of auto-submit and also fires
6584 + // on REST-created posts, where nothing else may have loaded
6585 + // the Google Index helpers yet — load them on demand instead
6586 + // of silently skipping.
6587 + if (!function_exists('google_index_direct')) {
6588 + $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
6589 + if (file_exists($google_index_path)) {
6590 + require_once $google_index_path;
6591 + }
6592 + }
6593 + if (function_exists('google_index_direct')) {
6594 + $service_info = google_index_direct()->get_service_account_info();
6595 + if (!isset($service_info['error'])) {
6596 + google_index_direct()->index_post($post_id, $post->post_type, 'update');
6597 + }
6446 6598 }
6447 6599 }
6448 6600 }
6449 6601