PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | admin/class-metasync-admin.php +510 -387 2.6.16 → 2.7.2 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
@@ -266,8 +263,15 @@
266 263
267 264 add_action('admin_init', array($this, 'initialize_cookie'));
268 265 add_action('admin_init', array($this, 'maybe_redirect_to_wizard'));
269 266
267 + // The redirect must run on admin_menu, not admin_init: in current
268 + // WordPress the unregistered-page access check in wp-admin/includes/
269 + // menu.php fires from wp-admin/menu.php (required at admin.php:163),
270 + // which is BEFORE admin_init (admin.php:180). Hooked on admin_init the
271 + // redirect never ran — WP died with a 403 first for the removed slug.
272 + add_action('admin_menu', array($this, 'redirect_legacy_instant_indexing_page'));
273 +
270 274 // Add admin_post hooks for form submissions (WordPress standard way - no output buffering needed)
271 275 add_action('admin_post_metasync_clear_all_cache_plugins', array($this, 'handle_clear_all_cache_plugins'));
272 276 add_action('admin_post_metasync_clear_otto_cache_all', array($this, 'handle_clear_otto_cache_all'));
273 277 add_action('admin_post_metasync_clear_otto_cache_url', array($this, 'handle_clear_otto_cache_url'));
@@ -284,8 +288,11 @@
284 288
285 289 // Add AJAX for saving execution settings
286 290 add_action( 'wp_ajax_metasync_save_execution_settings', array($this, 'ajax_save_execution_settings') );
287 291
292 + // Add AJAX for saving the Headless Mode demo controls on the Advanced tab
293 + add_action( 'wp_ajax_metasync_save_headless_settings', array(Metasync_Settings_Registration::instance(), 'ajax_save_headless_settings') );
294 +
288 295 // Add AJAX for saving hosting cache settings
289 296 add_action( 'wp_ajax_metasync_save_hosting_cache_settings', array($this, 'ajax_save_hosting_cache_settings') );
290 297
291 298 // Add AJAX for saving OTTO Cache TTL
@@ -315,8 +322,9 @@
315 322 add_action('wp_ajax_metasync_get_default_robots', array($this, 'ajax_get_default_robots'));
316 323 add_action('wp_ajax_metasync_preview_robots_backup', array($this, 'ajax_preview_robots_backup'));
317 324 add_action('wp_ajax_metasync_delete_robots_backup', array($this, 'ajax_delete_robots_backup'));
318 325 add_action('wp_ajax_metasync_restore_robots_backup', array($this, 'ajax_restore_robots_backup'));
326 + add_action('wp_ajax_metasync_get_robots_backups', array($this, 'ajax_get_robots_backups'));
319 327
320 328 // Add AJAX handlers for host blocking test
321 329 add_action('wp_ajax_metasync_test_host_blocking_get', array($this, 'ajax_test_host_blocking_get'));
322 330 add_action('wp_ajax_metasync_test_host_blocking_post', array($this, 'ajax_test_host_blocking_post'));
@@ -373,8 +381,15 @@
373 381 add_action('wp_ajax_metasync_process_batch_tick', array($this, 'ajax_process_batch_tick'));
374 382 add_action('wp_ajax_metasync_delete_orphaned_image', array($this, 'ajax_delete_orphaned_image'));
375 383 add_action('metasync_media_batch_optimize_cron', array($this, 'handle_media_batch_cron'));
376 384
385 + # SEO Restore (Rollback) AJAX & Cron handlers
386 + add_action('wp_ajax_metasync_seo_restore_start', array($this, 'ajax_seo_restore_start'));
387 + add_action('wp_ajax_metasync_seo_restore_cancel', array($this, 'ajax_seo_restore_cancel'));
388 + add_action('wp_ajax_metasync_seo_restore_progress', array($this, 'ajax_seo_restore_progress'));
389 + add_action('wp_ajax_metasync_seo_restore_process_tick', array($this, 'ajax_seo_restore_process_tick'));
390 + add_action('metasync_seo_restore_cron', array($this, 'handle_seo_restore_cron'));
391 +
377 392 # Add AJAX handlers for Google Instant Indexing
378 393 add_action('wp_ajax_metasync_send_giapi', array($this, 'ajax_send_giapi'));
379 394
380 395 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
@@ -379,11 +394,8 @@
379 394
380 395 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
381 396 add_action('wp_ajax_metasync_send_bing_indexnow', array($this, 'ajax_send_bing_indexnow'));
382 397
383 - # Add hooks for instant indexing settings saves
384 - add_action('admin_init', array($this, 'save_instant_indexing_settings'));
385 -
386 398 # Add hooks for instant indexing post actions
387 399 add_filter('post_row_actions', array($this, 'add_instant_indexing_post_actions'), 10, 2);
388 400 add_filter('page_row_actions', array($this, 'add_instant_indexing_post_actions'), 10, 2);
389 401
@@ -467,9 +479,9 @@
467 479
468 480 $data= Metasync::get_option('general');
469 481
470 482 # Get white label menu slug
471 - # Sanitize so a URL-shaped legacy value still yields a valid WP menu slug (WP-413)
483 + # Sanitize so a URL-shaped legacy value still yields a valid WP menu slug
472 484 $menu_slug = empty($data['white_label_plugin_menu_slug']) ? self::$page_slug : sanitize_title($data['white_label_plugin_menu_slug']);
473 485 $menu_slug = $menu_slug === '' ? self::$page_slug : $menu_slug;
474 486
475 487 ?>
@@ -488,9 +500,9 @@
488 500 <?php
489 501 }
490 502
491 503 public function metasync_fouc_prevention_style() {
492 - if ( ! isset( $_GET['page'] ) || strpos( $_GET['page'], self::$page_slug ) !== 0 ) {
504 + if ( ! isset( $_GET['page'] ) || strpos( sanitize_key(wp_unslash($_GET['page'])), self::$page_slug ) !== 0 ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check for an inline style
493 505 return;
494 506 }
495 507 ?>
496 508 <style>
@@ -498,9 +510,9 @@
498 510 /*
499 511 * WP prints admin notices at the top of #wpbody-content, then core's
500 512 * common.js relocates them next to .wp-header-end inside our wrap on
501 513 * 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).
514 + * flash above the dashboard header and shove the page down.
503 515 * Once relocated they're no longer a direct child here, so this rule
504 516 * stops matching and they fade in with the wrap. We mirror core's
505 517 * relocation set exactly (.notice/.updated/.error, minus .inline and
506 518 * .below-h2); .update-nag is left alone since core never moves it.
@@ -512,13 +524,13 @@
512 524 <?php
513 525 }
514 526
515 527 public function suppress_notices_on_wizard_page() {
516 - if ( ! isset( $_GET['page'] ) ) {
528 + if ( ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
517 529 return;
518 530 }
519 531
520 - $page = sanitize_text_field( wp_unslash( $_GET['page'] ) );
532 + $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
521 533
522 534 // Setup wizard: strip every notice for a fully focused, distraction-free screen.
523 535 if ( strpos( $page, '-setup-wizard' ) !== false ) {
524 536 remove_all_actions( 'admin_notices' );
@@ -636,9 +648,9 @@
636 648
637 649 #Redirect url to the new slug
638 650 $redirect_url = admin_url('admin.php?page=' . $current_slug);
639 651
640 - wp_redirect($redirect_url);
652 + wp_safe_redirect($redirect_url);
641 653 exit;
642 654 }
643 655 }
644 656
@@ -690,13 +702,11 @@
690 702 * @since 1.0.0
691 703 */
692 704 public function maybe_redirect_to_wizard() {
693 705 // Only redirect if wizard should be shown
694 - if (get_option('metasync_show_wizard') && !isset($_GET['page'])) {
695 - delete_option('metasync_show_wizard');
696 -
706 + if (get_option('metasync_show_wizard') && !isset($_GET['page'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- isset() routing check for a redirect to a fixed admin URL
697 707 // Don't redirect during AJAX, cron, or bulk activation
698 - if (wp_doing_ajax() || wp_doing_cron() || isset($_GET['activate-multi'])) {
708 + if (wp_doing_ajax() || wp_doing_cron() || isset($_GET['activate-multi'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- isset() routing check for a redirect to a fixed admin URL
699 709 return;
700 710 }
701 711
702 712 // Only redirect if user has access to the plugin
@@ -703,16 +713,66 @@
703 713 if (!Metasync::current_user_has_plugin_access()) {
704 714 return;
705 715 }
706 716
717 + delete_option('metasync_show_wizard');
707 718 wp_safe_redirect(admin_url('admin.php?page=' . self::$page_slug . '-setup-wizard'));
708 719 exit;
709 720 }
710 721 }
711 722
723 + /**
724 + * Redirect the removed standalone Instant Indexing page to Indexation Control.
725 + *
726 + * The menu entry is gone, but bookmarks and external links can still target
727 + * the old URL. Resolve the current white-label slug rather than assuming
728 + * "searchatlas".
729 + *
730 + * Hooked on admin_menu (see its registration): the unregistered-page 403 in
731 + * wp-admin/includes/menu.php fires before admin_init, so any later hook
732 + * would be too late to redirect.
733 + */
734 + public function redirect_legacy_instant_indexing_page() {
735 + if (wp_doing_ajax() || wp_doing_cron() || !is_admin() || !isset($_GET['page'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
736 + return;
737 + }
738 +
739 + $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
740 + $legacy_page = self::$page_slug . '-instant-index';
741 + if ($page !== $legacy_page || !Metasync::current_user_has_plugin_access()) {
742 + return;
743 + }
744 +
745 + // Indexation Control is registered only when access control keeps it
746 + // visible. Redirecting to it blindly would dead-end on an
747 + // unregistered slug (wp_die "Invalid plugin page") for users whose
748 + // white-label config hides that page — the exact configs where the
749 + // removed standalone page was the feature's only UI. The bare plugin
750 + // slug is the floor: its top-level callback resolves to the first
751 + // reachable page, so it is always a safe target.
752 + if (Metasync_Access_Control::user_can_access('hide_indexation_control')) {
753 + $target = admin_url('admin.php?page=' . self::$page_slug . '-seo-controls');
754 + $query = [];
755 + foreach (['tab', 'subtab'] as $key) {
756 + if (isset($_GET[$key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
757 + $query[$key] = sanitize_key(wp_unslash($_GET[$key])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
758 + }
759 + }
760 + if (!empty($query)) {
761 + $target = add_query_arg($query, $target);
762 + }
763 + } else {
764 + $target = admin_url('admin.php?page=' . self::$page_slug);
765 + }
766 +
767 + wp_safe_redirect($target);
768 + exit;
769 + }
770 +
712 771 public function metasync_display_error_log() {
713 772 Metasync_Debug_Manager::instance()->metasync_display_error_log($this);
714 773 }
774 +
715 775 public function metasync_update_wp_config() {
716 776 Metasync_Debug_Manager::instance()->metasync_update_wp_config();
717 777 }
718 778
@@ -755,9 +815,9 @@
755 815 ]);
756 816 $_COOKIE['metasync_previous_slug'] = $new_slug;
757 817 // Redirect to the new slug
758 818 $redirect_url = admin_url('admin.php?page=' . $old_slug);
759 - wp_redirect($redirect_url);
819 + wp_safe_redirect($redirect_url);
760 820 exit;
761 821 }else{
762 822 self::$page_slug = Metasync::get_option('general')['white_label_plugin_menu_slug']=="" ? "searchatlas":Metasync::get_option('general')['white_label_plugin_menu_slug'];
763 823 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
@@ -763,9 +823,9 @@
763 823 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
764 824
765 825 #add redirection for when the old slug is not defined
766 826 #this fixes the redirect issue #
767 - wp_redirect($redirect_url);
827 + wp_safe_redirect($redirect_url);
768 828 exit;
769 829 }
770 830 }
771 831 public function metasync_view_detials_url( $plugin_meta, $plugin_file, $plugin_data ) {
@@ -776,9 +836,10 @@
776 836 if (strpos($meta, 'open-plugin-details-modal') !== false) {
777 837 $meta = sprintf(
778 838 '<a href="%s" class="thickbox open-plugin-details-modal" aria-label="%s" data-title="%s">%s</a>',
779 839 add_query_arg('TB_iframe', 'true', $plugin_uri),
780 - esc_attr(sprintf(esc_html__('More information about %s'), $plugin_data['Name'])),
840 + /* translators: %s: plugin name. */
841 + esc_attr(sprintf(esc_html__('More information about %s', 'metasync'), $plugin_data['Name'])),
781 842 esc_attr($plugin_data['Name']),
782 843 esc_html__('View details', 'metasync')
783 844 );
784 845 break; // Exit loop after replacing the link
@@ -826,53 +887,8 @@
826 887 return $all_plugins;
827 888 }
828 889
829 890 /**
830 - * Inject the whitelabel icon into the update_plugins transient so that
831 - * /wp-admin/update-core.php (Dashboard → Updates) shows the WL icon
832 - * instead of the SearchAtlas icon returned by the update API.
833 - *
834 - * @param object $transient The site transient object.
835 - * @return object
836 - */
837 - public function inject_whitelabel_icon_into_update_transient($transient) {
838 - if (empty($transient) || !is_object($transient)) {
839 - return $transient;
840 - }
841 -
842 - $general = Metasync::get_option('general');
843 - if (!is_array($general)) {
844 - return $transient;
845 - }
846 -
847 - $icon_url = $general['white_label_plugin_menu_icon'] ?? '';
848 - if (empty($icon_url)) {
849 - return $transient;
850 - }
851 -
852 - $this_plugin = plugin_basename(dirname(__DIR__) . '/metasync.php');
853 -
854 - // Inject into pending updates list
855 - if (!empty($transient->response) && isset($transient->response[$this_plugin])) {
856 - $transient->response[$this_plugin]->icons = [
857 - '1x' => $icon_url,
858 - '2x' => $icon_url,
859 - ];
860 - }
861 -
862 - // Also inject into the "no update needed" list so the icon appears
863 - // on the updates screen even when the plugin is up-to-date
864 - if (!empty($transient->no_update) && isset($transient->no_update[$this_plugin])) {
865 - $transient->no_update[$this_plugin]->icons = [
866 - '1x' => $icon_url,
867 - '2x' => $icon_url,
868 - ];
869 - }
870 -
871 - return $transient;
872 - }
873 -
874 - /**
875 891 * Register the stylesheets for the admin area.
876 892 *
877 893 * @since 1.0.0
878 894 */
@@ -877,9 +893,9 @@
877 893 * @since 1.0.0
878 894 */
879 895 public function enqueue_styles()
880 896 {
881 - $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
897 + $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
882 898
883 899 if (strpos($current_page, self::$page_slug) === 0) {
884 900 wp_enqueue_style(
885 901 $this->plugin_name,
@@ -907,10 +923,11 @@
907 923 'all'
908 924 );
909 925 }
910 926
911 - // Enqueue wizard CSS if on wizard page
912 - if (isset($_GET['page']) && strpos($_GET['page'], '-setup-wizard') !== false) {
927 + // Enqueue wizard CSS on all MetaSync pages because the reusable consent
928 + // card is also displayed inside General Settings.
929 + if (strpos($current_page, self::$page_slug) === 0 || (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-setup-wizard') !== false)) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
913 930 wp_enqueue_style(
914 931 $this->plugin_name . '-setup-wizard',
915 932 plugin_dir_url(__FILE__) . 'css/metasync-setup-wizard.css',
916 933 array($this->plugin_name . '-dashboard'),
@@ -919,9 +936,9 @@
919 936 );
920 937 }
921 938
922 939 // Enqueue SEO Health CSS if on the SEO Health page
923 - if (isset($_GET['page']) && strpos($_GET['page'], '-seo-health') !== false) {
940 + if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-seo-health') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
924 941 wp_enqueue_style(
925 942 $this->plugin_name . '-seo-health',
926 943 plugin_dir_url(__FILE__) . 'css/metasync-seo-health.css',
927 944 array($this->plugin_name . '-dashboard'),
@@ -938,9 +955,9 @@
938 955 */
939 956 public function enqueue_scripts()
940 957 {
941 958 // --- Phase 5 (#887): Extracted inline JS files ---
942 - $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
959 + $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick scripts
943 960 $plugin_root_url = plugin_dir_url(dirname(__FILE__));
944 961 $is_metasync_page = ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0);
945 962
946 963 if ($is_metasync_page) {
@@ -970,10 +987,49 @@
970 987 array('jquery'),
971 988 $this->version,
972 989 true
973 990 );
991 +
992 + // Keeps core's admin-menu pinning in step with pages whose height
993 + // changes after load. Depends on 'common' so core has bound its
994 + // own handlers before this runs.
995 + wp_enqueue_script(
996 + $this->plugin_name . '-admin-menu-height',
997 + plugin_dir_url(__FILE__) . 'js/metasync-admin-menu-height.js',
998 + array('jquery', 'common'),
999 + $this->version,
1000 + true
1001 + );
974 1002 }
975 1003
1004 + $per_page_screens = [
1005 + self::$page_slug . '-redirections',
1006 + self::$page_slug . '-404-monitor',
1007 + self::$page_slug . '-sync-log',
1008 + self::$page_slug . '-media-optimization',
1009 + ];
1010 +
1011 + if (in_array($current_page, $per_page_screens, true)) {
1012 + // Results-per-page selectors use this external handler instead of
1013 + // an inline event attribute so changing a page size always reloads
1014 + // the current admin page.
1015 + wp_enqueue_script(
1016 + $this->plugin_name . '-per-page',
1017 + plugin_dir_url(__FILE__) . 'js/metasync-per-page.js',
1018 + array(),
1019 + $this->version,
1020 + true
1021 + );
1022 + // Selector navigation is a GET, so the per-page nonce rides along
1023 + // as a query parameter and gates the user_meta persist in
1024 + // Metasync_Per_Page_Helper::resolve().
1025 + wp_localize_script(
1026 + $this->plugin_name . '-per-page',
1027 + 'metasyncPerPage',
1028 + array('nonces' => Metasync_Per_Page_Helper::page_nonces())
1029 + );
1030 + }
1031 +
976 1032 // Dashboard iframe height (only on dashboard page)
977 1033 if ($current_page === self::$page_slug . '-dashboard' || $current_page === self::$page_slug) {
978 1034 wp_enqueue_script(
979 1035 $this->plugin_name . '-iframe',
@@ -1022,12 +1078,12 @@
1022 1078 true
1023 1079 );
1024 1080 // Pass the active tab from server (handles POST redirect)
1025 1081 $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']));
1082 + if (isset($_GET['tab']) && in_array($_GET['tab'], ['general', 'news', 'video'], true)) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed tab selection for the UI
1083 + $sitemap_active_tab = sanitize_text_field(wp_unslash($_GET['tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed tab selection for the UI
1084 + } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only, allow-listed tab selection for the UI
1085 + $sitemap_active_tab = sanitize_text_field(wp_unslash($_POST['redirect_tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only, allow-listed tab selection for the UI
1030 1086 }
1031 1087 wp_localize_script($this->plugin_name . '-sitemap-tabs', 'metasyncSitemapTabs', [
1032 1088 'activeTab' => $sitemap_active_tab,
1033 1089 ]);
@@ -1355,8 +1411,10 @@
1355 1411 'nonce' => wp_create_nonce('metasync_nonce'),
1356 1412 'sa_connect_nonce' => $sa_connect_nonce,
1357 1413 'reset_auth_nonce' => wp_create_nonce('metasync_reset_auth_nonce'),
1358 1414 'burst_ping_nonce' => wp_create_nonce('metasync_burst_ping'),
1415 + 'clear_otto_cache_nonce' => wp_create_nonce('metasync_clear_otto_cache'),
1416 + 'lglogin_nonce' => wp_create_nonce('metasync_lglogin'),
1359 1417 'heartbeat_state' => $heartbeat_state,
1360 1418 'dashboard_domain' => self::get_effective_dashboard_domain(),
1361 1419 'support_email' => Metasync::SUPPORT_EMAIL,
1362 1420 'documentation_domain' => Metasync::DOCUMENTATION_DOMAIN,
@@ -1420,9 +1478,9 @@
1420 1478 add_action('admin_notices', array($this, 'display_page_builder_notice'));
1421 1479 // Display update warning banner if plugin update is available
1422 1480 add_action('admin_notices', array($this, 'display_update_warning_banner'));
1423 1481 // Enqueue wizard assets if on wizard page
1424 - if (isset($_GET['page']) && strpos($_GET['page'], '-setup-wizard') !== false) {
1482 + if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-setup-wizard') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick scripts
1425 1483 wp_enqueue_script(
1426 1484 $this->plugin_name . '-setup-wizard',
1427 1485 plugin_dir_url(__FILE__) . 'js/metasync-setup-wizard.js',
1428 1486 array('jquery'),
@@ -1446,14 +1504,44 @@
1446 1504 }
1447 1505
1448 1506 /**
1449 1507 * Settings of HeartBeat API for admin area.
1450 - * Set time interval of send request.
1508 + *
1509 + * The only thing the plugin rides the beat for is a periodic admin
1510 + * telemetry ping (the `heartbeat-send` handler in admin/js/metasync-admin.js),
1511 + * so it slows the beat down rather than speeding it up. Two contexts are
1512 + * left on whatever core decided, because core leans on the beat there:
1513 + *
1514 + * - The front end, where the beat drives wp-auth-check — the "your session
1515 + * has expired, log in again" prompt.
1516 + * - The editor screens, where each tick renews the post lock. A lock only
1517 + * lives for wp_check_post_lock_window() seconds (150 by default), so any
1518 + * interval longer than that drops the lock between beats and lets a
1519 + * second editor silently take over a post someone still has open.
1520 + *
1521 + * Core clamps this to 1-3600 seconds in wp-includes/js/heartbeat.js, so an
1522 + * over-long interval is not corrected on our behalf.
1523 + *
1524 + * @param array $settings Heartbeat settings array.
1525 + * @return array Heartbeat settings array.
1451 1526 */
1452 1527 function metasync_heartbeat_settings($settings)
1453 1528 {
1454 - global $heartbeat_frequency;
1455 - $settings['interval'] = 300;
1529 + if (!is_admin()) {
1530 + return $settings;
1531 + }
1532 +
1533 + # $pagenow is set in wp-includes/vars.php, long before this filter runs;
1534 + # core's own wp_heartbeat_set_suspension() reads it exactly this way.
1535 + global $pagenow;
1536 + $lock_dependent_screens = array('post.php', 'post-new.php', 'customize.php');
1537 + if (isset($pagenow) && in_array($pagenow, $lock_dependent_screens, true)) {
1538 + return $settings;
1539 + }
1540 +
1541 + # 60s matches heartbeat.js's own default and stays well inside the
1542 + # 150s post-lock window, so nothing core does can fall through a gap.
1543 + $settings['interval'] = 60;
1456 1544 return $settings;
1457 1545 }
1458 1546
1459 1547 /**
@@ -1591,13 +1679,8 @@
1591 1679 {
1592 1680 Metasync_Connect_Manager::instance()->test_searchatlas_ajax_endpoint();
1593 1681 }
1594 1682
1595 - public function simple_ajax_test()
1596 - {
1597 - Metasync_Connect_Manager::instance()->simple_ajax_test();
1598 - }
1599 -
1600 1683 /**
1601 1684 * Create Admin Dashboard Iframe Page
1602 1685 * Embeds the Search Atlas dashboard directly in WordPress admin
1603 1686 */
@@ -1626,9 +1709,9 @@
1626 1709 <!-- OTTO Cache TTL Setting -->
1627 1710 <div style="margin-bottom: 30px; padding-top: 20px;">
1628 1711 <h4 style="margin-top: 0; color: var(--dashboard-text-primary);"><?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> Cache TTL</h4>
1629 1712 <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.
1713 + 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 1714 </p>
1632 1715
1633 1716 <div style="background: rgba(255,255,255,0.02); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
1634 1717 <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
@@ -1634,8 +1717,9 @@
1634 1717 <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
1635 1718 <label for="metasync-otto-cache-ttl" style="color: var(--dashboard-text-primary); font-weight: 500;">
1636 1719 Cache TTL (minutes):
1637 1720 </label>
1721 + <?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 1722 <input type="number"
1639 1723 id="metasync-otto-cache-ttl"
1640 1724 value="<?php echo esc_attr($this->get_otto_cache_ttl_minutes()); ?>"
1641 1725 min="30"
@@ -1683,9 +1767,9 @@
1683 1767 } else {
1684 1768 echo '<p style="color: var(--dashboard-text-secondary);">ℹ️ No cache plugins detected.</p>';
1685 1769 }
1686 1770 } catch (Exception $e) {
1687 - error_log('MetaSync Cache Status Error: ' . $e->getMessage());
1771 + error_log('MetaSync Cache Status Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1688 1772 echo '<p style="color: var(--dashboard-error);">⚠️ An error occurred while retrieving cache plugin status.</p>';
1689 1773 }
1690 1774 } else {
1691 1775 echo '<p style="color: var(--dashboard-error);">⚠️ Cache Purge class not loaded.</p>';
@@ -1702,12 +1786,12 @@
1702 1786 </form>
1703 1787
1704 1788 <?php
1705 1789 // 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'])) : '';
1790 + if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1791 + $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1792 + $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1793 + $plugins = isset($_GET['plugins']) ? sanitize_text_field(wp_unslash($_GET['plugins'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1710 1794
1711 1795 if ($cleared > 0) {
1712 1796 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1713 1797 echo '✅ <strong>Success!</strong> Cleared cache for ' . intval( $cleared ) . ' plugin(s)';
@@ -1727,10 +1811,10 @@
1727 1811 echo '</p></div>';
1728 1812 }
1729 1813 }
1730 1814
1731 - if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') {
1732 - $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : '';
1815 + if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1816 + $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1733 1817 if (empty($message)) {
1734 1818 $message = 'An unknown error occurred while clearing cache. Please check error logs for details.';
1735 1819 }
1736 1820 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
@@ -1779,8 +1863,9 @@
1779 1863 <?php checked(true, !empty($hosting_settings['wpengine_enabled'])); ?>
1780 1864 <?php echo !$wpe_detected ? 'disabled' : ''; ?>
1781 1865 style="width: 16px; height: 16px; cursor: <?php echo $wpe_detected ? 'pointer' : 'not-allowed'; ?>;" />
1782 1866 <span style="color: var(--dashboard-text-primary); font-weight: 500;">WP Engine</span>
1867 + <?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 1868 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges Varnish + Memcached</span>
1784 1869 </label>
1785 1870
1786 1871 <label style="display: flex; align-items: center; gap: 10px; margin-bottom: 16px; cursor: pointer;">
@@ -1789,8 +1874,9 @@
1789 1874 <?php checked(true, !empty($hosting_settings['kinsta_enabled'])); ?>
1790 1875 <?php echo !$kinsta_detected ? 'disabled' : ''; ?>
1791 1876 style="width: 16px; height: 16px; cursor: <?php echo $kinsta_detected ? 'pointer' : 'not-allowed'; ?>;" />
1792 1877 <span style="color: var(--dashboard-text-primary); font-weight: 500;">Kinsta</span>
1878 + <?php Metasync::render_tooltip_icon('hosting_cache_kinsta', 'If hosted on Kinsta, turn on so OTTO clears Kinsta\'s server cache automatically.'); ?>
1793 1879 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges full-page cache (kinsta_cache_purge_full)</span>
1794 1880 </label>
1795 1881
1796 1882 <div style="display: flex; align-items: center; gap: 12px;">
@@ -1825,12 +1911,13 @@
1825 1911 </form>
1826 1912
1827 1913 <?php
1828 1914 // 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'])) : '';
1915 + if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1916 + // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each value is unslashed and sanitized on the following assignments.
1917 + $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_cleared']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1918 + $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_failed']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1919 + $hc_not_detected = isset($_GET['hc_not_detected']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_not_detected']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1833 1920
1834 1921 if ($hc_cleared) {
1835 1922 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1836 1923 echo '✅ <strong>Success!</strong> Purged hosting cache on: ' . esc_html($hc_cleared);
@@ -1845,8 +1932,9 @@
1845 1932 echo '<div class="notice notice-info inline" style="margin-top: 10px;"><p>';
1846 1933 echo 'ℹ️ No enabled hosting providers were detected on this server (' . esc_html($hc_not_detected) . ').';
1847 1934 echo '</p></div>';
1848 1935 }
1936 + // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1849 1937 }
1850 1938 ?>
1851 1939
1852 1940 <script>
@@ -1938,9 +2026,9 @@
1938 2026 id="metasync-targeted-object-cache"
1939 2027 <?php checked('1', $targeted_cache_enabled); ?>
1940 2028 style="width: 16px; height: 16px; cursor: pointer; margin-top: 2px; flex-shrink: 0;" />
1941 2029 <span>
1942 - <span style="color: var(--dashboard-text-primary); font-weight: 500; display: block; margin-bottom: 4px;">Targeted Object Cache Purge</span>
2030 + <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 2031 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">
1944 2032 When enabled, only the updated posts are evicted from the object cache (recommended for large sites).
1945 2033 When disabled, a full <code>wp_cache_flush()</code> is used instead.
1946 2034 </span>
@@ -2011,11 +2099,11 @@
2011 2099 </div>
2012 2100
2013 2101 <?php
2014 2102 // 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']))) : '';
2103 + if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2104 + $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2105 + $url = isset($_GET['url']) ? urldecode(sanitize_text_field(wp_unslash($_GET['url']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2018 2106
2019 2107 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
2020 2108 if (!empty($url)) {
2021 2109 echo '✅ <strong>Success!</strong> Cleared cache for URL: <code>' . esc_html($url) . '</code> (' . intval( $cleared_count ) . ' entries)';
@@ -2024,10 +2112,10 @@
2024 2112 }
2025 2113 echo '</p></div>';
2026 2114 }
2027 2115
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.';
2116 + if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2117 + $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : 'An unknown error occurred.'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2030 2118 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
2031 2119 echo '❌ <strong>Error:</strong> ' . esc_html($message);
2032 2120 echo '</p></div>';
2033 2121 }
@@ -2066,9 +2154,9 @@
2066 2154 <td>
2067 2155 <input type="url"
2068 2156 id="otto_cache_url"
2069 2157 name="otto_cache_url"
2070 - value="<?php echo isset($_GET['url']) ? esc_attr(urldecode(sanitize_text_field(wp_unslash($_GET['url'])))) : ''; ?>"
2158 + value="<?php echo isset($_GET['url']) ? esc_attr(urldecode(sanitize_text_field(wp_unslash($_GET['url'])))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce ?>"
2071 2159 class="regular-text"
2072 2160 placeholder="https://example.com/page/"
2073 2161 required />
2074 2162 <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 +2227,10 @@
2139 2227 /**
2140 2228 * AJAX handler for saving hosting cache settings
2141 2229 */
2142 2230 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')) {
2231 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2232 + if (!isset($_POST['hosting_cache_nonce']) || !wp_verify_nonce(wp_unslash($_POST['hosting_cache_nonce']), 'metasync_hosting_cache_nonce')) {
2144 2233 wp_send_json_error(array('message' => 'Security check failed'));
2145 2234 return;
2146 2235 }
2147 2236
@@ -2162,9 +2251,10 @@
2162 2251 /**
2163 2252 * AJAX handler: save object cache behaviour settings
2164 2253 */
2165 2254 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')) {
2255 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2256 + if (!isset($_POST['object_cache_nonce']) || !wp_verify_nonce(wp_unslash($_POST['object_cache_nonce']), 'metasync_object_cache_nonce')) {
2167 2257 wp_send_json_error(array('message' => 'Security check failed'));
2168 2258 return;
2169 2259 }
2170 2260
@@ -2191,9 +2281,10 @@
2191 2281 /**
2192 2282 * AJAX handler for saving OTTO Cache TTL
2193 2283 */
2194 2284 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')) {
2285 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2286 + if (!isset($_POST['otto_cache_ttl_nonce']) || !wp_verify_nonce(wp_unslash($_POST['otto_cache_ttl_nonce']), 'metasync_otto_cache_ttl_nonce')) {
2196 2287 wp_send_json_error(array('message' => 'Security check failed.'));
2197 2288 return;
2198 2289 }
2199 2290
@@ -2219,9 +2310,10 @@
2219 2310 /**
2220 2311 * admin_post handler: purge WP Engine and Kinsta hosting-level caches
2221 2312 */
2222 2313 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')) {
2314 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2315 + if (!isset($_POST['hosting_cache_purge_nonce']) || !wp_verify_nonce(wp_unslash($_POST['hosting_cache_purge_nonce']), 'metasync_hosting_cache_purge_nonce')) {
2224 2316 wp_die('Security check failed');
2225 2317 }
2226 2318
2227 2319 if (!Metasync::current_user_has_plugin_access()) {
@@ -2241,9 +2333,9 @@
2241 2333 WpeCommon::purge_varnish_cache();
2242 2334 WpeCommon::purge_memcached();
2243 2335 $cleared[] = 'WP Engine';
2244 2336 } catch (Exception $e) {
2245 - error_log('MetaSync: WP Engine hosting cache purge failed - ' . $e->getMessage());
2337 + error_log('MetaSync: WP Engine hosting cache purge failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2246 2338 $failed[] = 'WP Engine';
2247 2339 }
2248 2340 } else {
2249 2341 $not_detected[] = 'WP Engine';
@@ -2256,9 +2348,9 @@
2256 2348 try {
2257 2349 KinstaCache::get_instance()->kinsta_cache_purge_full();
2258 2350 $cleared[] = 'Kinsta';
2259 2351 } catch (Exception $e) {
2260 - error_log('MetaSync: Kinsta hosting cache purge failed - ' . $e->getMessage());
2352 + error_log('MetaSync: Kinsta hosting cache purge failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2261 2353 $failed[] = 'Kinsta';
2262 2354 }
2263 2355 } else {
2264 2356 $not_detected[] = 'Kinsta';
@@ -2653,9 +2745,9 @@
2653 2745 # Initialize MetaSync API request class and trigger synchronization
2654 2746 (new Metasync_Sync_Requests())->SyncCustomerParams();
2655 2747 } catch (Exception $e) {
2656 2748 # Log any API request errors for debugging
2657 - error_log('Metasync API Error: ' . $e->getMessage());
2749 + error_log('Metasync API Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2658 2750 }
2659 2751 }
2660 2752
2661 2753 /*
@@ -2670,9 +2762,9 @@
2670 2762 # Initialize MetaSync API request class and trigger synchronization
2671 2763 (new Metasync_Sync_Requests())->SyncCustomerParams();
2672 2764 } catch (Exception $e) {
2673 2765 # Log any API request errors for debugging
2674 - error_log('Metasync API Error: ' . $e->getMessage());
2766 + error_log('Metasync API Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2675 2767 }
2676 2768 }
2677 2769
2678 2770 /**
@@ -2705,8 +2797,15 @@
2705 2797 // Handle form submissions
2706 2798 if (isset($_POST['metasync_media_optimization_nonce'])) {
2707 2799 check_admin_referer('metasync_save_media_optimization', 'metasync_media_optimization_nonce');
2708 2800
2801 + // Settings shape the whole site's conversion pipeline — restrict
2802 + // the save (and the reset) to site admins, not just anyone who can
2803 + // open the page via plugin_access_roles.
2804 + if (!current_user_can('manage_options')) {
2805 + wp_die(esc_html__('Sorry, you are not allowed to manage media optimization settings.', 'metasync'));
2806 + }
2807 +
2709 2808 // Handle reset to defaults
2710 2809 if (!empty($_POST['metasync_media_reset'])) {
2711 2810 $defaults = Metasync_Media_Settings::get_defaults();
2712 2811 Metasync_Media_Settings::save_settings($defaults);
@@ -2711,8 +2810,9 @@
2711 2810 $defaults = Metasync_Media_Settings::get_defaults();
2712 2811 Metasync_Media_Settings::save_settings($defaults);
2713 2812 $save_success = true;
2714 2813 } elseif (isset($_POST['metasync_media'])) {
2814 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Media_Settings::save_settings().
2715 2815 $input = wp_unslash($_POST['metasync_media']);
2716 2816 Metasync_Media_Settings::save_settings($input);
2717 2817 $save_success = true;
2718 2818 }
@@ -2744,8 +2844,15 @@
2744 2844 * Code Minification page callback
2745 2845 */
2746 2846 public function create_admin_code_minification_page()
2747 2847 {
2848 + // Settings shape the whole site's asset pipeline — restrict
2849 + // the save (and the reset) to site admins, not just anyone who can
2850 + // open the page via plugin_access_roles.
2851 + if (!empty($_POST) && !current_user_can('manage_options')) {
2852 + wp_die(esc_html__('Sorry, you are not allowed to manage code minification settings.', 'metasync'));
2853 + }
2854 +
2748 2855 $this->render_layout_open('Code Minification', 'code_minification', 'Minify CSS, JavaScript, and HTML to improve performance.');
2749 2856 // Load settings and compatibility classes
2750 2857 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-settings.php';
2751 2858 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-cache.php';
@@ -2762,8 +2869,9 @@
2762 2869 $defaults = Metasync_Minification_Settings::get_defaults();
2763 2870 Metasync_Minification_Settings::save_settings($defaults);
2764 2871 $save_success = true;
2765 2872 } elseif (isset($_POST['metasync_code_min'])) {
2873 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Minification_Settings::save_settings().
2766 2874 $input = (array) wp_unslash($_POST['metasync_code_min']);
2767 2875 Metasync_Minification_Settings::save_settings($input);
2768 2876 $save_success = true;
2769 2877 }
@@ -2796,8 +2904,15 @@
2796 2904 if (!$attachment_id) {
2797 2905 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2798 2906 }
2799 2907
2908 + // Ownership: under the replace strategy this permanently deletes the
2909 + // attachment's original file, so upload_files alone is not enough —
2910 + // the caller must be allowed to edit this specific attachment.
2911 + if (!current_user_can('edit_post', $attachment_id)) {
2912 + wp_send_json_error(__('Permission denied.', 'metasync'));
2913 + }
2914 +
2800 2915 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-settings.php';
2801 2916 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-image-converter.php';
2802 2917 $settings = Metasync_Media_Settings::get_settings();
2803 2918
@@ -2811,8 +2926,9 @@
2811 2926 $max_bytes = 10 * 1024 * 1024;
2812 2927 if (filesize($file) > $max_bytes) {
2813 2928 $size_mb = round(filesize($file) / 1024 / 1024, 1);
2814 2929 wp_send_json_error(sprintf(
2930 + /* translators: %s: file size in megabytes. */
2815 2931 __('Optimization skipped: file size (%s MB) exceeds the 10 MB safety limit to prevent memory issues.', 'metasync'),
2816 2932 $size_mb
2817 2933 ));
2818 2934 }
@@ -2849,8 +2965,20 @@
2849 2965 if (!$attachment_id) {
2850 2966 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2851 2967 }
2852 2968
2969 + // Ownership: reverting deletes the attachment's converted files, so
2970 + // restrict it to users who may edit this specific attachment.
2971 + if (!current_user_can('edit_post', $attachment_id)) {
2972 + wp_send_json_error(__('Permission denied.', 'metasync'));
2973 + }
2974 +
2975 + // Replace-strategy conversions have no original to restore — reverting
2976 + // would delete the attachment's only file. Refuse up front, like bulk revert does.
2977 + if (!Metasync_Image_Converter::can_revert($attachment_id)) {
2978 + wp_send_json_error(__('This image cannot be reverted. The original file no longer exists (replace strategy).', 'metasync'));
2979 + }
2980 +
2853 2981 $success = Metasync_Image_Converter::revert_attachment($attachment_id);
2854 2982
2855 2983 if ($success) {
2856 2984 wp_send_json_success();
@@ -2902,8 +3030,15 @@
2902 3030 public function ajax_batch_progress()
2903 3031 {
2904 3032 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
2905 3033
3034 + // Read-only progress/stats polling — the same capability that can
3035 + // open the media optimization page is enough, but a nonce alone
3036 + // never was.
3037 + if (!current_user_can('upload_files')) {
3038 + wp_send_json_error(__('Permission denied.', 'metasync'));
3039 + }
3040 +
2906 3041 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-batch-optimizer.php';
2907 3042 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
2908 3043
2909 3044 $progress = Metasync_Media_Batch_Optimizer::get_progress();
@@ -2934,10 +3069,19 @@
2934 3069 $settings = Metasync_Media_Settings::get_settings();
2935 3070
2936 3071 $success = 0;
2937 3072 $failed = 0;
3073 + $denied = 0;
2938 3074
2939 3075 foreach ($ids as $id) {
3076 + // Per-attachment ownership: replace mode deletes originals, so
3077 + // upload_files alone must not allow converting images the caller
3078 + // does not own/cannot edit.
3079 + if (!current_user_can('edit_post', $id)) {
3080 + $denied++;
3081 + continue;
3082 + }
3083 +
2940 3084 if (Metasync_Image_Converter::convert_attachment($id, $settings)) {
2941 3085 $success++;
2942 3086 } else {
2943 3087 $failed++;
@@ -2946,8 +3090,9 @@
2946 3090
2947 3091 wp_send_json_success([
2948 3092 'success' => $success,
2949 3093 'failed' => $failed,
3094 + 'denied' => $denied,
2950 3095 ]);
2951 3096 }
2952 3097
2953 3098 /**
@@ -2970,11 +3115,19 @@
2970 3115
2971 3116 $success = 0;
2972 3117 $failed = 0;
2973 3118 $skipped = 0;
3119 + $denied = 0;
2974 3120 $errors = [];
2975 3121
2976 3122 foreach ($ids as $id) {
3123 + // Per-attachment ownership: reverting deletes the attachment's
3124 + // converted files, so restrict it per image, not per role.
3125 + if (!current_user_can('edit_post', $id)) {
3126 + $denied++;
3127 + continue;
3128 + }
3129 +
2977 3130 $format = get_post_meta($id, '_metasync_converted_format', true);
2978 3131
2979 3132 if (!$format) {
2980 3133 $skipped++;
@@ -2984,8 +3137,9 @@
2984 3137 if (!Metasync_Image_Converter::can_revert($id)) {
2985 3138 $skipped++;
2986 3139 $file = get_attached_file($id);
2987 3140 $name = $file ? basename($file) : "ID {$id}";
3141 + /* translators: %s: attachment file name. */
2988 3142 $errors[] = sprintf(__('%s: skipped — original image unavailable.', 'metasync'), $name);
2989 3143 continue;
2990 3144 }
2991 3145
@@ -2994,8 +3148,9 @@
2994 3148 } else {
2995 3149 $failed++;
2996 3150 $file = get_attached_file($id);
2997 3151 $name = $file ? basename($file) : "ID {$id}";
3152 + /* translators: %s: attachment file name. */
2998 3153 $errors[] = sprintf(__('%s: revert failed (original file may not exist).', 'metasync'), $name);
2999 3154 }
3000 3155 }
3001 3156
@@ -3002,8 +3157,9 @@
3002 3157 wp_send_json_success([
3003 3158 'success' => $success,
3004 3159 'failed' => $failed,
3005 3160 'skipped' => $skipped,
3161 + 'denied' => $denied,
3006 3162 'errors' => $errors,
3007 3163 ]);
3008 3164 }
3009 3165
@@ -3079,8 +3235,86 @@
3079 3235 Metasync_Media_Batch_Optimizer::process_batch_tick();
3080 3236 }
3081 3237
3082 3238 /**
3239 + * AJAX: Start SEO Restore batch.
3240 + */
3241 + public function ajax_seo_restore_start()
3242 + {
3243 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3244 +
3245 + if (!current_user_can('manage_options')) {
3246 + wp_send_json_error(__('Permission denied.', 'metasync'));
3247 + }
3248 +
3249 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3250 +
3251 + $progress = Metasync_Seo_Restore::start_batch();
3252 + wp_send_json_success($progress);
3253 + }
3254 +
3255 + /**
3256 + * AJAX: Cancel SEO Restore batch.
3257 + */
3258 + public function ajax_seo_restore_cancel()
3259 + {
3260 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3261 +
3262 + if (!current_user_can('manage_options')) {
3263 + wp_send_json_error(__('Permission denied.', 'metasync'));
3264 + }
3265 +
3266 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3267 +
3268 + Metasync_Seo_Restore::cancel_batch();
3269 + wp_send_json_success();
3270 + }
3271 +
3272 + /**
3273 + * AJAX: Get SEO Restore batch progress.
3274 + */
3275 + public function ajax_seo_restore_progress()
3276 + {
3277 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3278 +
3279 + if (!current_user_can('manage_options')) {
3280 + wp_send_json_error(__('Permission denied.', 'metasync'));
3281 + }
3282 +
3283 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3284 +
3285 + $progress = Metasync_Seo_Restore::get_progress();
3286 + wp_send_json_success($progress);
3287 + }
3288 +
3289 + /**
3290 + * AJAX: Process one SEO Restore tick (browser-driven chaining).
3291 + */
3292 + public function ajax_seo_restore_process_tick()
3293 + {
3294 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3295 +
3296 + if (!current_user_can('manage_options')) {
3297 + wp_send_json_error(__('Permission denied.', 'metasync'));
3298 + }
3299 +
3300 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3301 +
3302 + $progress = Metasync_Seo_Restore::process_ajax_tick();
3303 + wp_send_json_success($progress);
3304 + }
3305 +
3306 + /**
3307 + * Cron handler: Process SEO Restore batch tick.
3308 + */
3309 + public function handle_seo_restore_cron()
3310 + {
3311 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3312 +
3313 + Metasync_Seo_Restore::process_batch_tick();
3314 + }
3315 +
3316 + /**
3083 3317 * Report Issue page callback
3084 3318 */
3085 3319 public function create_admin_report_issue_page()
3086 3320 {
@@ -3092,8 +3326,17 @@
3092 3326 * XML Sitemap page callback
3093 3327 */
3094 3328 public function create_admin_xml_sitemap_page()
3095 3329 {
3330 + // Every form action below mutates site-wide state — sitemap options,
3331 + // sitemap files on disk, robots.txt, other plugins' sitemap generators
3332 + // — so they are restricted to site admins, not just anyone who can
3333 + // open the page via plugin_access_roles. The nonce is CSRF
3334 + // protection, not authorization.
3335 + if (!empty($_POST) && !current_user_can('manage_options')) {
3336 + wp_die(esc_html__('Sorry, you are not allowed to manage sitemap settings.', 'metasync'));
3337 + }
3338 +
3096 3339 // Load sitemap generator class
3097 3340 require_once plugin_dir_path(dirname(__FILE__)) . 'sitemap/class-metasync-sitemap-generator.php';
3098 3341
3099 3342 $sitemap_generator = new Metasync_Sitemap_Generator();
@@ -3121,13 +3364,16 @@
3121 3364
3122 3365 update_option('metasync_sitemap_settings', $sitemap_settings);
3123 3366
3124 3367 // Regenerate sitemap with new content settings
3125 - $result = $sitemap_generator->generate_sitemap();
3368 + $result = $sitemap_generator->generate_sitemap( true );
3126 3369 if (is_wp_error($result)) {
3127 3370 echo '<div class="notice notice-error"><p>' . esc_html(
3371 + /* translators: %s: error message. */
3128 3372 sprintf(__('Settings saved but sitemap generation failed: %s', 'metasync'), $result->get_error_message())
3129 3373 ) . '</p></div>';
3374 + } elseif (false === $result) {
3375 + echo '<div class="notice notice-error"><p>' . esc_html__('Settings saved but sitemap generation failed: the sitemap data could not be stored, so no sitemap is being served. Check your object cache and database write settings, then try again.', 'metasync') . '</p></div>';
3130 3376 } else {
3131 3377 echo '<div class="notice notice-success"><p>' . esc_html__('Sitemap content settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3132 3378 }
3133 3379 }
@@ -3138,10 +3384,10 @@
3138 3384
3139 3385 // Build generic taxonomy filters
3140 3386 $news_taxonomies = [];
3141 3387 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);
3388 + foreach (wp_unslash($_POST['news_taxonomies']) as $tax_name => $term_ids) {
3389 + $news_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) wp_unslash($term_ids));
3144 3390 }
3145 3391 }
3146 3392
3147 3393 $news_settings = [
@@ -3166,9 +3412,9 @@
3166 3412 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3167 3413 } else {
3168 3414 // Also remove physical file if it exists
3169 3415 if (file_exists(ABSPATH . 'news-sitemap.xml')) {
3170 - @unlink(ABSPATH . 'news-sitemap.xml');
3416 + @wp_delete_file(ABSPATH . 'news-sitemap.xml');
3171 3417 }
3172 3418 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
3173 3419 }
3174 3420 }
@@ -3179,10 +3425,10 @@
3179 3425
3180 3426 // Build generic taxonomy filters
3181 3427 $video_taxonomies = [];
3182 3428 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);
3429 + foreach (wp_unslash($_POST['video_taxonomies']) as $tax_name => $term_ids) {
3430 + $video_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) wp_unslash($term_ids));
3185 3431 }
3186 3432 }
3187 3433
3188 3434 $video_settings = [
@@ -3190,8 +3436,12 @@
3190 3436 'post_types' => array_map('sanitize_key', (array) ($_POST['video_post_types'] ?? ['post', 'page'])),
3191 3437 'auto_detect' => isset($_POST['auto_detect']),
3192 3438 'taxonomies' => $video_taxonomies,
3193 3439 'excluded_urls' => sanitize_textarea_field(wp_unslash($_POST['video_excluded_urls'] ?? '')),
3440 + // This array replaces the stored option wholesale, so every
3441 + // setting must be represented here or it is wiped on save.
3442 + 'video_url_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_url_meta_keys'] ?? '')),
3443 + 'video_thumbnail_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_thumbnail_meta_keys'] ?? '')),
3194 3444 ];
3195 3445
3196 3446 // Always invalidate old cache before saving new settings
3197 3447 delete_transient('metasync_vsm_' . md5('video-sitemap.xml'));
@@ -3204,9 +3454,9 @@
3204 3454 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3205 3455 } else {
3206 3456 // Also remove physical file if it exists
3207 3457 if (file_exists(ABSPATH . 'video-sitemap.xml')) {
3208 - @unlink(ABSPATH . 'video-sitemap.xml');
3458 + @wp_delete_file(ABSPATH . 'video-sitemap.xml');
3209 3459 }
3210 3460 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
3211 3461 }
3212 3462 }
@@ -3249,8 +3499,17 @@
3249 3499 if ($sitemap_generator->generate_news_sitemap()) {
3250 3500 $extras[] = 'news';
3251 3501 }
3252 3502 }
3503 +
3504 + // Reclaim unreferenced memory between the news and video passes
3505 + // so the video generator starts with more headroom (they share
3506 + // one request). Each generator already releases its own object
3507 + // cache via clean_post_cache(), so no cache flush is needed here.
3508 + if (function_exists('gc_collect_cycles')) {
3509 + gc_collect_cycles();
3510 + }
3511 +
3253 3512 if (!empty($video_opts['enabled'])) {
3254 3513 if ($sitemap_generator->generate_video_sitemap()) {
3255 3514 $extras[] = 'video';
3256 3515 }
@@ -3255,21 +3514,30 @@
3255 3514 $extras[] = 'video';
3256 3515 }
3257 3516 }
3258 3517
3518 + // Same inter-pass GC before the main sitemap pass begins.
3519 + if (function_exists('gc_collect_cycles')) {
3520 + gc_collect_cycles();
3521 + }
3522 +
3259 3523 // Generate main sitemap (its index will include news/video since they now exist)
3260 - $result = $sitemap_generator->generate_sitemap();
3524 + $result = $sitemap_generator->generate_sitemap( true );
3261 3525
3262 3526 if (is_wp_error($result)) {
3263 3527 $error_msg = $result->get_error_message();
3264 - error_log('[MetaSync] Sitemap generation failed: ' . $error_msg);
3528 + error_log('[MetaSync] Sitemap generation failed: ' . $error_msg); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3265 3529 echo '<div class="notice notice-error"><p>' . esc_html(
3530 + /* translators: %s: error message. */
3266 3531 sprintf(__('Sitemap generation failed: %s', 'metasync'), $error_msg)
3267 3532 ) . '</p></div>';
3533 + } elseif (false === $result) {
3534 + echo '<div class="notice notice-error"><p>' . esc_html__('Sitemap generation failed: the sitemap data could not be stored, so no sitemap is being served. Check your object cache and database write settings, then try again.', 'metasync') . '</p></div>';
3268 3535 } else {
3269 3536 $message = esc_html__('Sitemap generated successfully!', 'metasync');
3270 3537 if (!empty($extras)) {
3271 3538 $message .= ' ' . sprintf(
3539 + /* translators: %s: list of sitemap types. */
3272 3540 esc_html__('Also generated %s sitemap(s).', 'metasync'),
3273 3541 implode(' & ', $extras)
3274 3542 );
3275 3543 }
@@ -3304,9 +3572,9 @@
3304 3572
3305 3573 if ($deleted) {
3306 3574 // Disable auto-update only when the general sitemap is removed
3307 3575 update_option('metasync_sitemap_auto_update', false);
3308 - // Re-enable WP core sitemap only if no other MetaSync sitemaps remain (WP-396)
3576 + // Re-enable WP core sitemap only if no other MetaSync sitemaps remain
3309 3577 if (!$sitemap_generator->sitemap_exists()) {
3310 3578 delete_option('metasync_disable_wp_sitemap');
3311 3579 }
3312 3580 echo '<div class="notice notice-success"><p>' . esc_html__('General sitemap deleted successfully!', 'metasync') . '</p></div>';
@@ -3335,9 +3603,9 @@
3335 3603
3336 3604 if ($deleted) {
3337 3605 // Also disable auto-update when deleting
3338 3606 update_option('metasync_sitemap_auto_update', false);
3339 - // Re-enable WP core sitemap so the site isn't left with zero sitemaps (WP-396)
3607 + // Re-enable WP core sitemap so the site isn't left with zero sitemaps
3340 3608 delete_option('metasync_disable_wp_sitemap');
3341 3609 echo '<div class="notice notice-success"><p>' . esc_html__('All sitemaps deleted successfully!', 'metasync') . '</p></div>';
3342 3610 } else {
3343 3611 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 +3726,8 @@
3458 3726 Metasync_Admin_Pages::get_instance($this)->create_admin_breadcrumbs_page();
3459 3727 }
3460 3728
3461 3729 /**
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 3730 * Google Console page callback
3502 3731 */
3503 3732 public function create_admin_google_console_page()
3504 3733 {
@@ -3521,16 +3750,8 @@
3521 3750 $this->render_layout_close();
3522 3751 }
3523 3752
3524 3753 /**
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 3754 * Global Options page callback
3534 3755 */
3535 3756 public function create_admin_global_settings_page()
3536 3757 {
@@ -3562,16 +3783,8 @@
3562 3783 Metasync_Admin_Pages::get_instance($this)->create_admin_seo_controls_page();
3563 3784 }
3564 3785
3565 3786 /**
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 3787 * redirection page callback with tabs
3575 3788 */
3576 3789 public function create_admin_redirections_page()
3577 3790 {
@@ -3620,8 +3833,9 @@
3620 3833 if (!get_transient('metasync_llms_conflict')) {
3621 3834 return;
3622 3835 }
3623 3836 echo '<div class="notice notice-info is-dismissible"><p>';
3837 + /* translators: %s: effective plugin name (whitelabel-aware). */
3624 3838 echo esc_html(sprintf(__('Note: Another SEO plugin (Yoast, Rank Math, or AIOSEO) may also be generating /llms.txt. %s\'s version takes priority when enabled.', 'metasync'), Metasync::get_effective_plugin_name()));
3625 3839 echo '</p></div>';
3626 3840 }
3627 3841
@@ -3691,8 +3905,17 @@
3691 3905 public function ajax_restore_robots_backup()
3692 3906 {
3693 3907 Metasync_Admin_Ajax::instance()->ajax_restore_robots_backup();
3694 3908 }
3909 +
3910 + /**
3911 + * AJAX handler to fetch a paginated page of robots.txt backups
3912 + */
3913 + public function ajax_get_robots_backups()
3914 + {
3915 + Metasync_Admin_Ajax::instance()->ajax_get_robots_backups();
3916 + }
3917 +
3695 3918 public function ajax_create_redirect_from_404()
3696 3919 {
3697 3920 Metasync_Admin_Ajax::instance()->ajax_create_redirect_from_404();
3698 3921 }
@@ -3720,9 +3943,9 @@
3720 3943 {
3721 3944 register_rest_route('metasync/v1', '/ping', array(
3722 3945 'methods' => array('GET', 'POST'),
3723 3946 'callback' => array($this, 'handle_ping_rest_endpoint'),
3724 - 'permission_callback' => '__return_true', // Allow public access
3947 + 'permission_callback' => '__return_true',
3725 3948 'args' => array(
3726 3949 'test' => array(
3727 3950 'description' => 'Optional test parameter',
3728 3951 'type' => 'string',
@@ -3804,17 +4027,17 @@
3804 4027 return;
3805 4028 }
3806 4029
3807 4030 // Get pagination parameters
3808 - $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
3809 - $per_page = 10;
4031 + $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
4032 + $per_page = Metasync_Per_Page_Helper::resolve('sync_log', 10);
3810 4033 $offset = ($page - 1) * $per_page;
3811 4034
3812 4035 // Get filters
3813 4036 $filters = [
3814 4037 // 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'])) : '',
4038 + 'date_range' => isset($_GET['date_range']) ? sanitize_text_field(wp_unslash($_GET['date_range'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
4039 + 'status' => isset($_GET['status']) ? sanitize_text_field(wp_unslash($_GET['status'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
3817 4040 ];
3818 4041
3819 4042 // Map date_range to concrete date_from/date_to for DB queries
3820 4043 $date_range = $filters['date_range'];
@@ -3823,28 +4046,28 @@
3823 4046 $date_to = '';
3824 4047
3825 4048 if (!empty($date_range)) {
3826 4049 // End boundary is now by default
3827 - $date_to = date('Y-m-d H:i:s', $wp_now_ts);
4050 + $date_to = gmdate('Y-m-d H:i:s', $wp_now_ts);
3828 4051
3829 4052 if ($date_range === 'today') {
3830 4053 $start_ts = strtotime('today', $wp_now_ts);
3831 - $date_from = date('Y-m-d H:i:s', $start_ts);
4054 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3832 4055 } elseif ($date_range === 'yesterday') {
3833 4056 $start_ts = strtotime('yesterday', $wp_now_ts);
3834 4057 $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);
4058 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
4059 + $date_to = gmdate('Y-m-d H:i:s', $end_ts);
3837 4060 } elseif ($date_range === 'this_week') {
3838 4061 $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
4062 + $day_of_week = (int) gmdate('w', $wp_now_ts); // 0=Sun..6=Sat
3840 4063 // Convert start_of_week to PHP's 0..6 where 0=Sunday
3841 4064 $delta_days = ($day_of_week - $start_of_week + 7) % 7;
3842 4065 $start_ts = strtotime('-' . $delta_days . ' days', strtotime('today', $wp_now_ts));
3843 - $date_from = date('Y-m-d H:i:s', $start_ts);
4066 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3844 4067 } 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);
4068 + $start_ts = strtotime(gmdate('Y-m-01 00:00:00', $wp_now_ts));
4069 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3847 4070 } elseif ($date_range === 'all') {
3848 4071 // no bounds
3849 4072 }
3850 4073 }
@@ -3871,12 +4094,24 @@
3871 4094 ?>
3872 4095 <div class="dashboard-card">
3873 4096 <div class="sync-log-header">
3874 4097 <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>
4098 + <?php
4099 + // The page title and description are already rendered by
4100 + // render_layout_open() above. Repeating them here printed
4101 + // "Changes Log" twice on screen, so the card keeps only the
4102 + // retention hint — the one piece of copy the layout header
4103 + // does not carry.
4104 + ?>
4105 + <p style="color: var(--dashboard-text-secondary); margin-top: 0; margin-bottom: 0;">
4106 + <?php
4107 + // No opacity here: this used to be a de-emphasised
4108 + // aside trailing a full-contrast sentence, but it is
4109 + // now the card's only copy. At 12px/.75 it composites
4110 + // to 4.28:1 on the card background, under the 4.5:1
4111 + // AA floor; at full strength it reads 6.52:1.
4112 + ?>
4113 + <span style="font-size:13px;">Records are automatically removed after 90 days.</span>
3879 4114 </p>
3880 4115 </div>
3881 4116
3882 4117 <!-- Filters + Clear Log button - Right aligned -->
@@ -3886,9 +4121,9 @@
3886 4121 data-nonce="<?php echo esc_attr(wp_create_nonce('metasync_clear_sync_log')); ?>">
3887 4122 🗑 Clear Log
3888 4123 </button>
3889 4124 <form method="get" class="sync-filters-form" onchange="this.submit()" style="display:flex;flex-direction:row;align-items:center;gap:12px;flex-wrap:nowrap;">
3890 - <input type="hidden" name="page" value="<?php echo esc_attr($_GET['page']); ?>">
4125 + <input type="hidden" name="page" value="<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>">
3891 4126
3892 4127 <select name="date_range" class="sync-filter-select">
3893 4128 <option value="all" <?php selected($filters['date_range'] ?? 'all', 'all'); ?>> All Time</option>
3894 4129 <option value="today" <?php selected($filters['date_range'] ?? '', 'today'); ?>>Today</option>
@@ -3910,9 +4145,9 @@
3910 4145 <div class="sync-log-list">
3911 4146 <?php if (empty($sync_records)): ?>
3912 4147 <div class="sync-log-empty">
3913 4148 <div class="sync-log-empty-icon"><span class="dashicons dashicons-media-default" style="font-size:48px;width:48px;height:48px;color:var(--dashboard-text-secondary);"></span></div>
3914 - <h3>No sync records found</h3>
4149 + <h2>No sync records found</h2>
3915 4150 <p>Sync records will appear here when content/pages receive new updates.</p>
3916 4151 </div>
3917 4152 <?php else: ?>
3918 4153 <?php foreach ($sync_records as $record): ?>
@@ -3966,30 +4201,32 @@
3966 4201 <?php endif; ?>
3967 4202 </div>
3968 4203
3969 4204 <!-- 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>
4205 + <div class="sync-log-pagination">
4206 + <div class="sync-log-pagination-info">
4207 + Total records: <?php echo intval( $total_records ); ?><?php if ($total_records > 0): ?> | Showing <?php echo intval( $offset ) + 1; ?>-<?php echo intval( min($offset + $per_page, $total_records) ); ?><?php endif; ?>
4208 + <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper returns a complete escaped selector element.
4209 + echo Metasync_Per_Page_Helper::render_selector('sync_log', absint($per_page)); ?>
4210 + </div>
3975 4211
4212 + <?php if ($total_pages > 1): ?>
3976 4213 <div class="sync-log-pagination-controls">
3977 4214 <?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>
4215 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $page ) - 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">‹</a>
3979 4216 <?php endif; ?>
3980 4217
3981 4218 <?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) ); ?>"
4219 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $i ); ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>"
3983 4220 class="sync-pagination-btn <?php echo $i === $page ? 'active' : ''; ?>"><?php echo intval( $i ); ?></a>
3984 4221 <?php endfor; ?>
3985 4222
3986 4223 <?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>
4224 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $page ) + 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">›</a>
3988 4225 <?php endif; ?>
3989 4226 </div>
3990 - </div>
3991 - <?php endif; ?>
4227 + <?php endif; ?>
4228 + </div>
3992 4229 </div>
3993 4230 <?php $this->render_layout_close(); ?>
3994 4231
3995 4232 <script>
@@ -4070,8 +4307,19 @@
4070 4307 if (!empty($value)) {
4071 4308 $query_parts[] = $key . '=' . urlencode($value);
4072 4309 }
4073 4310 }
4311 +
4312 + // Preserve the user-selected results-per-page value across page
4313 + // navigation so a non-default page size survives clicking a page link.
4314 + $per_page_key = Metasync_Per_Page_Helper::request_key('sync_log');
4315 + if (isset($_GET[$per_page_key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4316 + $per_page = (int) $_GET[$per_page_key]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4317 + if (in_array($per_page, Metasync_Per_Page_Helper::allowed_values(), true)) {
4318 + $query_parts[] = $per_page_key . '=' . $per_page;
4319 + }
4320 + }
4321 +
4074 4322 return !empty($query_parts) ? '&' . implode('&', $query_parts) : '';
4075 4323 }
4076 4324
4077 4325 /**
@@ -4245,18 +4493,8 @@
4245 4493 {
4246 4494 Metasync_Connect_Manager::instance()->handle_session_management_early();
4247 4495 }
4248 4496
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 4497 private function handle_whitelabel_session_logic()
4260 4498 {
4261 4499 Metasync_Connect_Manager::instance()->handle_whitelabel_session_logic();
4262 4500 }
@@ -4330,8 +4568,9 @@
4330 4568 <!-- Per-Core Load Threshold -->
4331 4569 <div style="margin-bottom: 24px;">
4332 4570 <label for="cpu_load_per_core_threshold" style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4333 4571 Per-Core Load Threshold
4572 + <?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 4573 </label>
4335 4574 <input type="number"
4336 4575 id="cpu_load_per_core_threshold"
4337 4576 name="metasync_options[performance][cpu_load_per_core_threshold]"
@@ -4351,9 +4590,9 @@
4351 4590 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4352 4591 Effective Threshold
4353 4592 </label>
4354 4593 <div style="padding: 10px 12px; background: var(--dashboard-input-bg); border: 1px solid var(--dashboard-border); border-radius: 6px; color: var(--dashboard-text-secondary);">
4355 - <strong id="effective_threshold_value"><?php echo round($effective_threshold, 2); ?></strong>
4594 + <strong id="effective_threshold_value"><?php echo esc_html((string) round($effective_threshold, 2)); ?></strong>
4356 4595 </div>
4357 4596 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
4358 4597 Calculated as: cores × per-core threshold
4359 4598 </p>
@@ -4371,13 +4610,13 @@
4371 4610 <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo intval($stats['deferrals']); ?></div>
4372 4611 </div>
4373 4612 <div>
4374 4613 <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>
4614 + <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo esc_html((string) round($stats['max_load'], 2)); ?></div>
4376 4615 </div>
4377 4616 <div>
4378 4617 <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>
4618 + <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo esc_html((string) round($stats['avg_load'], 2)); ?></div>
4380 4619 </div>
4381 4620 </div>
4382 4621 </div>
4383 4622
@@ -4883,88 +5122,8 @@
4883 5122
4884 5123 /**
4885 5124 * Get the settings option array and print one of its values
4886 5125 */
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 5126 public function common_robot_meta_tags_callback()
4968 5127 {
4969 5128 Metasync_Settings_Fields::instance()->common_robot_meta_tags_callback();
4970 5129 }
@@ -5162,8 +5321,14 @@
5162 5321 * Show a one-time admin notice when a page builder is detected but the
5163 5322 * "Default Page Builder" setting has never been explicitly saved.
5164 5323 */
5165 5324 public function display_page_builder_notice() {
5325 + // Only relevant on the plugin's own settings page — avoid repeating
5326 + // this notice across every admin screen.
5327 + if (!isset($_GET['page']) || wp_unslash($_GET['page']) !== self::$page_slug) {
5328 + return;
5329 + }
5330 +
5166 5331 $configured = Metasync::get_option('general')['default_page_builder'] ?? '';
5167 5332
5168 5333 // Setting already saved — nothing to warn about
5169 5334 if (!empty($configured)) {
@@ -5176,9 +5341,9 @@
5176 5341 return;
5177 5342 }
5178 5343
5179 5344 // Handle dismiss action
5180 - if (isset($_GET['metasync_dismiss_builder_notice']) && wp_verify_nonce($_GET['_wpnonce'] ?? '', 'metasync_dismiss_builder')) {
5345 + if (isset($_GET['metasync_dismiss_builder_notice'], $_GET['_wpnonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'metasync_dismiss_builder')) {
5181 5346 update_user_meta(get_current_user_id(), 'metasync_builder_notice_dismissed', '1');
5182 5347 return;
5183 5348 }
5184 5349
@@ -5273,9 +5438,10 @@
5273 5438 * Saves the CPU load threshold and returns statistics
5274 5439 */
5275 5440 public function ajax_save_performance_settings() {
5276 5441 # 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')) {
5442 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5443 + if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce(wp_unslash($_POST['meta_sync_nonce']), 'meta_sync_general_setting_nonce')) {
5278 5444 wp_send_json_error(array('message' => 'Invalid nonce'));
5279 5445 return;
5280 5446 }
5281 5447
@@ -5343,9 +5509,9 @@
5343 5509 if (!wp_next_scheduled('metasync_cleanup_transients')) {
5344 5510 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_cleanup_transients');
5345 5511
5346 5512 if (!$scheduled) {
5347 - error_log('MetaSync: Failed to schedule transient cleanup cron job');
5513 + error_log('MetaSync: Failed to schedule transient cleanup cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5348 5514 }
5349 5515 }
5350 5516 }
5351 5517
@@ -5356,9 +5522,9 @@
5356 5522 {
5357 5523 $timestamp = wp_next_scheduled('metasync_cleanup_transients');
5358 5524 if ($timestamp) {
5359 5525 wp_unschedule_event($timestamp, 'metasync_cleanup_transients');
5360 - error_log('MetaSync: Transient cleanup cron job unscheduled');
5526 + error_log('MetaSync: Transient cleanup cron job unscheduled'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- unreachable in production
5361 5527 }
5362 5528 }
5363 5529
5364 5530 /**
@@ -5380,12 +5546,10 @@
5380 5546 {
5381 5547 if (!wp_next_scheduled('metasync_hidden_post_check')) {
5382 5548 $scheduled = wp_schedule_event(time(), 'metasync_weekly', 'metasync_hidden_post_check');
5383 5549
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');
5550 + if (!$scheduled) {
5551 + error_log('MetaSync: Failed to schedule hidden post manager cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5388 5552 }
5389 5553 }
5390 5554 }
5391 5555
@@ -5396,12 +5560,10 @@
5396 5560 public function maybe_schedule_otto_recheck_404_cron()
5397 5561 {
5398 5562 if (!wp_next_scheduled('metasync_otto_recheck_404_exclusions')) {
5399 5563 $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');
5564 + if (!$scheduled) {
5565 + error_log('MetaSync: Failed to schedule OTTO 404 recheck cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5404 5566 }
5405 5567 }
5406 5568 }
5407 5569
@@ -5474,13 +5636,13 @@
5474 5636
5475 5637 try {
5476 5638 // 1. Post revisions
5477 5639 if (!empty($settings['clean_post_revisions'])) {
5478 - $stats['post_revisions'] = (int) $wpdb->query(
5640 + $stats['post_revisions'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5479 5641 "DELETE FROM {$wpdb->posts} WHERE post_type = 'revision'"
5480 5642 );
5481 5643 // Remove postmeta left behind by deleted revisions
5482 - $wpdb->query(
5644 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5483 5645 "DELETE pm FROM {$wpdb->postmeta} pm
5484 5646 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5485 5647 WHERE p.ID IS NULL"
5486 5648 );
@@ -5488,20 +5650,19 @@
5488 5650
5489 5651 // 2. Trashed posts + their postmeta
5490 5652 if (!empty($settings['clean_trashed_posts'])) {
5491 5653 // Collect IDs first to cleanly remove postmeta
5492 - $trashed_ids = $wpdb->get_col(
5654 + $trashed_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5493 5655 "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'trash'"
5494 5656 );
5495 5657 if (!empty($trashed_ids)) {
5496 - $placeholders = implode(',', array_fill(0, count($trashed_ids), '%d'));
5497 - $wpdb->query(
5658 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5498 5659 $wpdb->prepare(
5499 - "DELETE FROM {$wpdb->postmeta} WHERE post_id IN ($placeholders)",
5660 + 'DELETE FROM ' . $wpdb->postmeta . ' WHERE post_id IN (' . implode(',', array_fill(0, count($trashed_ids), '%d')) . ')',
5500 5661 $trashed_ids
5501 5662 )
5502 5663 );
5503 - $stats['trashed_posts'] = (int) $wpdb->query(
5664 + $stats['trashed_posts'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5504 5665 "DELETE FROM {$wpdb->posts} WHERE post_status = 'trash'"
5505 5666 );
5506 5667 } else {
5507 5668 $stats['trashed_posts'] = 0;
@@ -5509,9 +5670,9 @@
5509 5670 }
5510 5671
5511 5672 // 3. Trashed comments
5512 5673 if (!empty($settings['clean_trashed_comments'])) {
5513 - $stats['trashed_comments'] = (int) $wpdb->query(
5674 + $stats['trashed_comments'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5514 5675 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'trash'"
5515 5676 );
5516 5677 }
5517 5678
@@ -5516,9 +5677,9 @@
5516 5677 }
5517 5678
5518 5679 // 4. Spam comments
5519 5680 if (!empty($settings['clean_spam_comments'])) {
5520 - $stats['spam_comments'] = (int) $wpdb->query(
5681 + $stats['spam_comments'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5521 5682 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'spam'"
5522 5683 );
5523 5684 }
5524 5685
@@ -5524,15 +5685,15 @@
5524 5685
5525 5686 // 5. Expired transients — direct SQL, no cache flush
5526 5687 if (!empty($settings['clean_expired_transients'])) {
5527 5688 // Delete timeout rows that have already expired
5528 - $wpdb->query(
5689 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5529 5690 "DELETE FROM {$wpdb->options}
5530 5691 WHERE option_name LIKE '\_transient\_timeout\_%'
5531 5692 AND option_value + 0 < UNIX_TIMESTAMP()"
5532 5693 );
5533 5694 // Delete value rows whose timeout row no longer exists
5534 - $stats['expired_transients'] = (int) $wpdb->query(
5695 + $stats['expired_transients'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5535 5696 "DELETE o FROM {$wpdb->options} o
5536 5697 LEFT JOIN {$wpdb->options} t
5537 5698 ON t.option_name = CONCAT('_transient_timeout_', SUBSTRING(o.option_name, 12))
5538 5699 WHERE o.option_name LIKE '\_transient\_%'
@@ -5542,9 +5703,9 @@
5542 5703 }
5543 5704
5544 5705 // 6. Orphaned postmeta (post_id references a post that no longer exists)
5545 5706 if (!empty($settings['clean_orphaned_postmeta'])) {
5546 - $stats['orphaned_postmeta'] = (int) $wpdb->query(
5707 + $stats['orphaned_postmeta'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5547 5708 "DELETE pm FROM {$wpdb->postmeta} pm
5548 5709 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5549 5710 WHERE p.ID IS NULL"
5550 5711 );
@@ -5556,12 +5717,10 @@
5556 5717 $settings['last_run_at'] = time();
5557 5718 $settings['last_run_stats'] = $stats;
5558 5719 update_option('metasync_db_cleanup_settings', $settings);
5559 5720
5560 - error_log('MetaSync: DB cleanup completed — ' . json_encode($stats));
5561 -
5562 5721 } catch (Exception $e) {
5563 - error_log('MetaSync: DB cleanup failed — ' . $e->getMessage());
5722 + error_log('MetaSync: DB cleanup failed — ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5564 5723 }
5565 5724 }
5566 5725
5567 5726 /**
@@ -5778,10 +5937,11 @@
5778 5937 /**
5779 5938 * AJAX: Save DB cleanup settings and reschedule cron accordingly.
5780 5939 */
5781 5940 public function ajax_save_db_cleanup_settings() {
5941 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5782 5942 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5783 - !wp_verify_nonce($_POST['db_cleanup_settings_nonce'], 'metasync_db_cleanup_settings_nonce')) {
5943 + !wp_verify_nonce(wp_unslash($_POST['db_cleanup_settings_nonce']), 'metasync_db_cleanup_settings_nonce')) {
5784 5944 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
5785 5945 return;
5786 5946 }
5787 5947
@@ -5838,10 +5998,11 @@
5838 5998 * AJAX: Manually trigger the DB cleanup and return stats for the UI.
5839 5999 * Persists current form state first so unsaved checkbox changes are respected.
5840 6000 */
5841 6001 public function ajax_run_db_cleanup() {
6002 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5842 6003 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5843 - !wp_verify_nonce($_POST['db_cleanup_settings_nonce'], 'metasync_db_cleanup_settings_nonce')) {
6004 + !wp_verify_nonce(wp_unslash($_POST['db_cleanup_settings_nonce']), 'metasync_db_cleanup_settings_nonce')) {
5844 6005 wp_send_json_error(array('message' => 'Invalid security token.'));
5845 6006 return;
5846 6007 }
5847 6008
@@ -5917,35 +6078,8 @@
5917 6078 ));
5918 6079 }
5919 6080
5920 6081 /**
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 6082 * AJAX handler to add excluded URL for OTTO
5949 6083 */
5950 6084 public function ajax_otto_add_excluded_url()
5951 6085 {
@@ -6091,8 +6225,9 @@
6091 6225 // If page has raw HTML or custom CSS from conversion, show badge
6092 6226 if ($has_raw_html || !empty($has_custom_css)) {
6093 6227 $label = $this->get_html_source_label();
6094 6228 $tooltip = sprintf(
6229 + /* translators: %s: HTML source label. */
6095 6230 __('This page was created using %s HTML-to-Builder converter', 'metasync'),
6096 6231 $label
6097 6232 );
6098 6233
@@ -6134,9 +6269,9 @@
6134 6269
6135 6270 // Don't show the "HTML-to-Builder converter" banner on LPS-synced / custom-HTML
6136 6271 // pages: those are raw-HTML store-and-serve pages, NOT actually converted to a
6137 6272 // page builder, so the banner mislabels them. It still shows for pages genuinely
6138 - // produced by the converter. (WP-486)
6273 + // produced by the converter.
6139 6274 if (function_exists('metasync_is_custom_or_lps_page') && metasync_is_custom_or_lps_page($post->ID)) {
6140 6275 return;
6141 6276 }
6142 6277
@@ -6145,8 +6280,9 @@
6145 6280
6146 6281 if ($has_raw_html || !empty($has_custom_css)) {
6147 6282 $label = $this->get_html_source_label();
6148 6283 $message = sprintf(
6284 + /* translators: %s: HTML source label. */
6149 6285 __('This page was created using %s HTML-to-Builder converter. The design is preserved with custom CSS and inline styles.', 'metasync'),
6150 6286 '<strong>' . esc_html($label) . '</strong>'
6151 6287 );
6152 6288
@@ -6160,9 +6296,9 @@
6160 6296 <p class="metasync-editor-message">%s</p>
6161 6297 </div>
6162 6298 </div>',
6163 6299 esc_html($label),
6164 - $message
6300 + wp_kses($message, array('strong' => array()))
6165 6301 );
6166 6302 }
6167 6303 }
6168 6304
@@ -6185,9 +6321,9 @@
6185 6321 ?>
6186 6322 <fieldset class="inline-edit-col-left metasync-quick-edit-source">
6187 6323 <div class="inline-edit-col">
6188 6324 <label>
6189 - <span class="title"><?php _e('Source', 'metasync'); ?></span>
6325 + <span class="title"><?php esc_html_e('Source', 'metasync'); ?></span>
6190 6326 <span class="metasync-quick-edit-badge-container"></span>
6191 6327 </label>
6192 6328 </div>
6193 6329 </fieldset>
@@ -6199,8 +6335,9 @@
6199 6335 */
6200 6336 public function add_html_pages_dashboard_widget()
6201 6337 {
6202 6338 $label = $this->get_html_source_label();
6339 + /* translators: %s: HTML source label. */
6203 6340 $widget_title = sprintf(__('%s Pages', 'metasync'), $label);
6204 6341
6205 6342 wp_add_dashboard_widget(
6206 6343 'metasync_html_pages_widget',
@@ -6341,34 +6478,8 @@
6341 6478 $bing_instant_index->send();
6342 6479 }
6343 6480
6344 6481 /**
6345 - * Save instant indexing settings (Google and Bing)
6346 - *
6347 - * @since 2.6.0
6348 - * @return void
6349 - */
6350 - public function save_instant_indexing_settings()
6351 - {
6352 - // Check if this is a settings submission
6353 - if (!isset($_POST['submit'])) {
6354 - return;
6355 - }
6356 -
6357 - // Save post types for Google Instant Indexing auto-submit
6358 - if (isset($_POST['metasync_post_types'])) {
6359 - $post_data = metasync_sanitize_input_array($_POST);
6360 - $post_types = is_array($post_data['metasync_post_types']) ? array_map('sanitize_title', $post_data['metasync_post_types']) : [];
6361 -
6362 - $settings = get_option('metasync_options_instant_indexing', ['post_types' => []]);
6363 - $settings['post_types'] = array_values($post_types);
6364 - update_option('metasync_options_instant_indexing', $settings);
6365 - }
6366 -
6367 - // Note: Bing Instant Indexing settings are saved via AJAX in save_bing_inline_settings_ajax()
6368 - }
6369 -
6370 - /**
6371 6482 * Save Bing instant indexing settings from inline form (Indexation Control page)
6372 6483 *
6373 6484 * @since 2.6.0
6374 6485 * @return bool True on success, false on failure
@@ -6438,12 +6549,24 @@
6438 6549 if (!empty($seo_controls['enable_googleinstantindex']) && $seo_controls['enable_googleinstantindex'] === 'true') {
6439 6550 $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
6440 6551 $post_types = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
6441 6552
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');
6553 + if (in_array($post->post_type, $post_types)) {
6554 + // save_post is the single owner of auto-submit and also fires
6555 + // on REST-created posts, where nothing else may have loaded
6556 + // the Google Index helpers yet — load them on demand instead
6557 + // of silently skipping.
6558 + if (!function_exists('google_index_direct')) {
6559 + $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
6560 + if (file_exists($google_index_path)) {
6561 + require_once $google_index_path;
6562 + }
6563 + }
6564 + if (function_exists('google_index_direct')) {
6565 + $service_info = google_index_direct()->get_service_account_info();
6566 + if (!isset($service_info['error'])) {
6567 + google_index_direct()->index_post($post_id, $post->post_type, 'update');
6568 + }
6446 6569 }
6447 6570 }
6448 6571 }
6449 6572