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 +432 -278 2.6.22 → 2.7.2 View file →
@@ -41,9 +41,8 @@
41 41 const SECTION_SEO_CONTROLS = "seo_controls";
42 42 const SECTION_SEO_CONTROLS_ADVANCED = "seo_controls_advanced";
43 43 const SECTION_SEO_CONTROLS_INSTANT_INDEX = "seo_controls_instant_index";
44 44 const SECTION_PLUGIN_VISIBILITY = "plugin_visibility_settings";
45 - const SECTION_BREADCRUMBS = "breadcrumbs_settings";
46 45 const SECTION_LLMS_TXT = "llms_txt_settings";
47 46
48 47 /**
49 48 * The ID of this plugin.
@@ -213,9 +212,8 @@
213 212 add_action('admin_menu', array($this, 'add_import_external_data_page'));
214 213 add_action('admin_init', array($this, 'settings_page_init'));
215 214 add_filter('all_plugins', array($this,'metasync_plugin_white_label'));
216 215 add_filter( 'plugin_row_meta',array($this,'metasync_view_detials_url'),10,3);
217 - add_filter('site_transient_update_plugins', array($this, 'inject_whitelabel_icon_into_update_transient'));
218 216
219 217 // Display transient error/success messages for redirections
220 218 add_action('admin_notices', array($this, 'display_redirection_messages'));
221 219
@@ -265,8 +263,15 @@
265 263
266 264 add_action('admin_init', array($this, 'initialize_cookie'));
267 265 add_action('admin_init', array($this, 'maybe_redirect_to_wizard'));
268 266
267 + // The redirect must run on admin_menu, not admin_init: in current
268 + // WordPress the unregistered-page access check in wp-admin/includes/
269 + // menu.php fires from wp-admin/menu.php (required at admin.php:163),
270 + // which is BEFORE admin_init (admin.php:180). Hooked on admin_init the
271 + // redirect never ran — WP died with a 403 first for the removed slug.
272 + add_action('admin_menu', array($this, 'redirect_legacy_instant_indexing_page'));
273 +
269 274 // Add admin_post hooks for form submissions (WordPress standard way - no output buffering needed)
270 275 add_action('admin_post_metasync_clear_all_cache_plugins', array($this, 'handle_clear_all_cache_plugins'));
271 276 add_action('admin_post_metasync_clear_otto_cache_all', array($this, 'handle_clear_otto_cache_all'));
272 277 add_action('admin_post_metasync_clear_otto_cache_url', array($this, 'handle_clear_otto_cache_url'));
@@ -283,8 +288,11 @@
283 288
284 289 // Add AJAX for saving execution settings
285 290 add_action( 'wp_ajax_metasync_save_execution_settings', array($this, 'ajax_save_execution_settings') );
286 291
292 + // Add AJAX for saving the Headless Mode demo controls on the Advanced tab
293 + add_action( 'wp_ajax_metasync_save_headless_settings', array(Metasync_Settings_Registration::instance(), 'ajax_save_headless_settings') );
294 +
287 295 // Add AJAX for saving hosting cache settings
288 296 add_action( 'wp_ajax_metasync_save_hosting_cache_settings', array($this, 'ajax_save_hosting_cache_settings') );
289 297
290 298 // Add AJAX for saving OTTO Cache TTL
@@ -373,8 +381,15 @@
373 381 add_action('wp_ajax_metasync_process_batch_tick', array($this, 'ajax_process_batch_tick'));
374 382 add_action('wp_ajax_metasync_delete_orphaned_image', array($this, 'ajax_delete_orphaned_image'));
375 383 add_action('metasync_media_batch_optimize_cron', array($this, 'handle_media_batch_cron'));
376 384
385 + # SEO Restore (Rollback) AJAX & Cron handlers
386 + add_action('wp_ajax_metasync_seo_restore_start', array($this, 'ajax_seo_restore_start'));
387 + add_action('wp_ajax_metasync_seo_restore_cancel', array($this, 'ajax_seo_restore_cancel'));
388 + add_action('wp_ajax_metasync_seo_restore_progress', array($this, 'ajax_seo_restore_progress'));
389 + add_action('wp_ajax_metasync_seo_restore_process_tick', array($this, 'ajax_seo_restore_process_tick'));
390 + add_action('metasync_seo_restore_cron', array($this, 'handle_seo_restore_cron'));
391 +
377 392 # Add AJAX handlers for Google Instant Indexing
378 393 add_action('wp_ajax_metasync_send_giapi', array($this, 'ajax_send_giapi'));
379 394
380 395 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
@@ -379,11 +394,8 @@
379 394
380 395 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
381 396 add_action('wp_ajax_metasync_send_bing_indexnow', array($this, 'ajax_send_bing_indexnow'));
382 397
383 - # Add hooks for instant indexing settings saves
384 - add_action('admin_init', array($this, 'save_instant_indexing_settings'));
385 -
386 398 # Add hooks for instant indexing post actions
387 399 add_filter('post_row_actions', array($this, 'add_instant_indexing_post_actions'), 10, 2);
388 400 add_filter('page_row_actions', array($this, 'add_instant_indexing_post_actions'), 10, 2);
389 401
@@ -488,9 +500,9 @@
488 500 <?php
489 501 }
490 502
491 503 public function metasync_fouc_prevention_style() {
492 - if ( ! isset( $_GET['page'] ) || strpos( $_GET['page'], self::$page_slug ) !== 0 ) {
504 + if ( ! isset( $_GET['page'] ) || strpos( sanitize_key(wp_unslash($_GET['page'])), self::$page_slug ) !== 0 ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check for an inline style
493 505 return;
494 506 }
495 507 ?>
496 508 <style>
@@ -512,13 +524,13 @@
512 524 <?php
513 525 }
514 526
515 527 public function suppress_notices_on_wizard_page() {
516 - if ( ! isset( $_GET['page'] ) ) {
528 + if ( ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
517 529 return;
518 530 }
519 531
520 - $page = sanitize_text_field( wp_unslash( $_GET['page'] ) );
532 + $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
521 533
522 534 // Setup wizard: strip every notice for a fully focused, distraction-free screen.
523 535 if ( strpos( $page, '-setup-wizard' ) !== false ) {
524 536 remove_all_actions( 'admin_notices' );
@@ -636,9 +648,9 @@
636 648
637 649 #Redirect url to the new slug
638 650 $redirect_url = admin_url('admin.php?page=' . $current_slug);
639 651
640 - wp_redirect($redirect_url);
652 + wp_safe_redirect($redirect_url);
641 653 exit;
642 654 }
643 655 }
644 656
@@ -690,13 +702,11 @@
690 702 * @since 1.0.0
691 703 */
692 704 public function maybe_redirect_to_wizard() {
693 705 // Only redirect if wizard should be shown
694 - if (get_option('metasync_show_wizard') && !isset($_GET['page'])) {
695 - delete_option('metasync_show_wizard');
696 -
706 + if (get_option('metasync_show_wizard') && !isset($_GET['page'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- isset() routing check for a redirect to a fixed admin URL
697 707 // Don't redirect during AJAX, cron, or bulk activation
698 - if (wp_doing_ajax() || wp_doing_cron() || isset($_GET['activate-multi'])) {
708 + if (wp_doing_ajax() || wp_doing_cron() || isset($_GET['activate-multi'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- isset() routing check for a redirect to a fixed admin URL
699 709 return;
700 710 }
701 711
702 712 // Only redirect if user has access to the plugin
@@ -703,16 +713,66 @@
703 713 if (!Metasync::current_user_has_plugin_access()) {
704 714 return;
705 715 }
706 716
717 + delete_option('metasync_show_wizard');
707 718 wp_safe_redirect(admin_url('admin.php?page=' . self::$page_slug . '-setup-wizard'));
708 719 exit;
709 720 }
710 721 }
711 722
723 + /**
724 + * Redirect the removed standalone Instant Indexing page to Indexation Control.
725 + *
726 + * The menu entry is gone, but bookmarks and external links can still target
727 + * the old URL. Resolve the current white-label slug rather than assuming
728 + * "searchatlas".
729 + *
730 + * Hooked on admin_menu (see its registration): the unregistered-page 403 in
731 + * wp-admin/includes/menu.php fires before admin_init, so any later hook
732 + * would be too late to redirect.
733 + */
734 + public function redirect_legacy_instant_indexing_page() {
735 + if (wp_doing_ajax() || wp_doing_cron() || !is_admin() || !isset($_GET['page'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
736 + return;
737 + }
738 +
739 + $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
740 + $legacy_page = self::$page_slug . '-instant-index';
741 + if ($page !== $legacy_page || !Metasync::current_user_has_plugin_access()) {
742 + return;
743 + }
744 +
745 + // Indexation Control is registered only when access control keeps it
746 + // visible. Redirecting to it blindly would dead-end on an
747 + // unregistered slug (wp_die "Invalid plugin page") for users whose
748 + // white-label config hides that page — the exact configs where the
749 + // removed standalone page was the feature's only UI. The bare plugin
750 + // slug is the floor: its top-level callback resolves to the first
751 + // reachable page, so it is always a safe target.
752 + if (Metasync_Access_Control::user_can_access('hide_indexation_control')) {
753 + $target = admin_url('admin.php?page=' . self::$page_slug . '-seo-controls');
754 + $query = [];
755 + foreach (['tab', 'subtab'] as $key) {
756 + if (isset($_GET[$key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
757 + $query[$key] = sanitize_key(wp_unslash($_GET[$key])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
758 + }
759 + }
760 + if (!empty($query)) {
761 + $target = add_query_arg($query, $target);
762 + }
763 + } else {
764 + $target = admin_url('admin.php?page=' . self::$page_slug);
765 + }
766 +
767 + wp_safe_redirect($target);
768 + exit;
769 + }
770 +
712 771 public function metasync_display_error_log() {
713 772 Metasync_Debug_Manager::instance()->metasync_display_error_log($this);
714 773 }
774 +
715 775 public function metasync_update_wp_config() {
716 776 Metasync_Debug_Manager::instance()->metasync_update_wp_config();
717 777 }
718 778
@@ -755,9 +815,9 @@
755 815 ]);
756 816 $_COOKIE['metasync_previous_slug'] = $new_slug;
757 817 // Redirect to the new slug
758 818 $redirect_url = admin_url('admin.php?page=' . $old_slug);
759 - wp_redirect($redirect_url);
819 + wp_safe_redirect($redirect_url);
760 820 exit;
761 821 }else{
762 822 self::$page_slug = Metasync::get_option('general')['white_label_plugin_menu_slug']=="" ? "searchatlas":Metasync::get_option('general')['white_label_plugin_menu_slug'];
763 823 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
@@ -763,9 +823,9 @@
763 823 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
764 824
765 825 #add redirection for when the old slug is not defined
766 826 #this fixes the redirect issue #
767 - wp_redirect($redirect_url);
827 + wp_safe_redirect($redirect_url);
768 828 exit;
769 829 }
770 830 }
771 831 public function metasync_view_detials_url( $plugin_meta, $plugin_file, $plugin_data ) {
@@ -776,9 +836,10 @@
776 836 if (strpos($meta, 'open-plugin-details-modal') !== false) {
777 837 $meta = sprintf(
778 838 '<a href="%s" class="thickbox open-plugin-details-modal" aria-label="%s" data-title="%s">%s</a>',
779 839 add_query_arg('TB_iframe', 'true', $plugin_uri),
780 - esc_attr(sprintf(esc_html__('More information about %s'), $plugin_data['Name'])),
840 + /* translators: %s: plugin name. */
841 + esc_attr(sprintf(esc_html__('More information about %s', 'metasync'), $plugin_data['Name'])),
781 842 esc_attr($plugin_data['Name']),
782 843 esc_html__('View details', 'metasync')
783 844 );
784 845 break; // Exit loop after replacing the link
@@ -826,53 +887,8 @@
826 887 return $all_plugins;
827 888 }
828 889
829 890 /**
830 - * Inject the whitelabel icon into the update_plugins transient so that
831 - * /wp-admin/update-core.php (Dashboard → Updates) shows the WL icon
832 - * instead of the SearchAtlas icon returned by the update API.
833 - *
834 - * @param object $transient The site transient object.
835 - * @return object
836 - */
837 - public function inject_whitelabel_icon_into_update_transient($transient) {
838 - if (empty($transient) || !is_object($transient)) {
839 - return $transient;
840 - }
841 -
842 - $general = Metasync::get_option('general');
843 - if (!is_array($general)) {
844 - return $transient;
845 - }
846 -
847 - $icon_url = $general['white_label_plugin_menu_icon'] ?? '';
848 - if (empty($icon_url)) {
849 - return $transient;
850 - }
851 -
852 - $this_plugin = plugin_basename(dirname(__DIR__) . '/metasync.php');
853 -
854 - // Inject into pending updates list
855 - if (!empty($transient->response) && isset($transient->response[$this_plugin])) {
856 - $transient->response[$this_plugin]->icons = [
857 - '1x' => $icon_url,
858 - '2x' => $icon_url,
859 - ];
860 - }
861 -
862 - // Also inject into the "no update needed" list so the icon appears
863 - // on the updates screen even when the plugin is up-to-date
864 - if (!empty($transient->no_update) && isset($transient->no_update[$this_plugin])) {
865 - $transient->no_update[$this_plugin]->icons = [
866 - '1x' => $icon_url,
867 - '2x' => $icon_url,
868 - ];
869 - }
870 -
871 - return $transient;
872 - }
873 -
874 - /**
875 891 * Register the stylesheets for the admin area.
876 892 *
877 893 * @since 1.0.0
878 894 */
@@ -877,9 +893,9 @@
877 893 * @since 1.0.0
878 894 */
879 895 public function enqueue_styles()
880 896 {
881 - $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
897 + $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
882 898
883 899 if (strpos($current_page, self::$page_slug) === 0) {
884 900 wp_enqueue_style(
885 901 $this->plugin_name,
@@ -907,10 +923,11 @@
907 923 'all'
908 924 );
909 925 }
910 926
911 - // Enqueue wizard CSS if on wizard page
912 - if (isset($_GET['page']) && strpos($_GET['page'], '-setup-wizard') !== false) {
927 + // Enqueue wizard CSS on all MetaSync pages because the reusable consent
928 + // card is also displayed inside General Settings.
929 + if (strpos($current_page, self::$page_slug) === 0 || (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-setup-wizard') !== false)) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
913 930 wp_enqueue_style(
914 931 $this->plugin_name . '-setup-wizard',
915 932 plugin_dir_url(__FILE__) . 'css/metasync-setup-wizard.css',
916 933 array($this->plugin_name . '-dashboard'),
@@ -919,9 +936,9 @@
919 936 );
920 937 }
921 938
922 939 // Enqueue SEO Health CSS if on the SEO Health page
923 - if (isset($_GET['page']) && strpos($_GET['page'], '-seo-health') !== false) {
940 + if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-seo-health') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
924 941 wp_enqueue_style(
925 942 $this->plugin_name . '-seo-health',
926 943 plugin_dir_url(__FILE__) . 'css/metasync-seo-health.css',
927 944 array($this->plugin_name . '-dashboard'),
@@ -938,9 +955,9 @@
938 955 */
939 956 public function enqueue_scripts()
940 957 {
941 958 // --- Phase 5 (#887): Extracted inline JS files ---
942 - $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
959 + $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick scripts
943 960 $plugin_root_url = plugin_dir_url(dirname(__FILE__));
944 961 $is_metasync_page = ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0);
945 962
946 963 if ($is_metasync_page) {
@@ -970,8 +987,19 @@
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
976 1004 $per_page_screens = [
977 1005 self::$page_slug . '-redirections',
@@ -990,8 +1018,16 @@
990 1018 array(),
991 1019 $this->version,
992 1020 true
993 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 + );
994 1030 }
995 1031
996 1032 // Dashboard iframe height (only on dashboard page)
997 1033 if ($current_page === self::$page_slug . '-dashboard' || $current_page === self::$page_slug) {
@@ -1042,12 +1078,12 @@
1042 1078 true
1043 1079 );
1044 1080 // Pass the active tab from server (handles POST redirect)
1045 1081 $sitemap_active_tab = 'general';
1046 - if (isset($_GET['tab']) && in_array($_GET['tab'], ['general', 'news', 'video'], true)) {
1047 - $sitemap_active_tab = sanitize_text_field(wp_unslash($_GET['tab']));
1048 - } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) {
1049 - $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
1050 1086 }
1051 1087 wp_localize_script($this->plugin_name . '-sitemap-tabs', 'metasyncSitemapTabs', [
1052 1088 'activeTab' => $sitemap_active_tab,
1053 1089 ]);
@@ -1375,8 +1411,10 @@
1375 1411 'nonce' => wp_create_nonce('metasync_nonce'),
1376 1412 'sa_connect_nonce' => $sa_connect_nonce,
1377 1413 'reset_auth_nonce' => wp_create_nonce('metasync_reset_auth_nonce'),
1378 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'),
1379 1417 'heartbeat_state' => $heartbeat_state,
1380 1418 'dashboard_domain' => self::get_effective_dashboard_domain(),
1381 1419 'support_email' => Metasync::SUPPORT_EMAIL,
1382 1420 'documentation_domain' => Metasync::DOCUMENTATION_DOMAIN,
@@ -1440,9 +1478,9 @@
1440 1478 add_action('admin_notices', array($this, 'display_page_builder_notice'));
1441 1479 // Display update warning banner if plugin update is available
1442 1480 add_action('admin_notices', array($this, 'display_update_warning_banner'));
1443 1481 // Enqueue wizard assets if on wizard page
1444 - 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
1445 1483 wp_enqueue_script(
1446 1484 $this->plugin_name . '-setup-wizard',
1447 1485 plugin_dir_url(__FILE__) . 'js/metasync-setup-wizard.js',
1448 1486 array('jquery'),
@@ -1466,14 +1504,44 @@
1466 1504 }
1467 1505
1468 1506 /**
1469 1507 * Settings of HeartBeat API for admin area.
1470 - * 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.
1471 1526 */
1472 1527 function metasync_heartbeat_settings($settings)
1473 1528 {
1474 - global $heartbeat_frequency;
1475 - $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;
1476 1544 return $settings;
1477 1545 }
1478 1546
1479 1547 /**
@@ -1611,13 +1679,8 @@
1611 1679 {
1612 1680 Metasync_Connect_Manager::instance()->test_searchatlas_ajax_endpoint();
1613 1681 }
1614 1682
1615 - public function simple_ajax_test()
1616 - {
1617 - Metasync_Connect_Manager::instance()->simple_ajax_test();
1618 - }
1619 -
1620 1683 /**
1621 1684 * Create Admin Dashboard Iframe Page
1622 1685 * Embeds the Search Atlas dashboard directly in WordPress admin
1623 1686 */
@@ -1646,9 +1709,9 @@
1646 1709 <!-- OTTO Cache TTL Setting -->
1647 1710 <div style="margin-bottom: 30px; padding-top: 20px;">
1648 1711 <h4 style="margin-top: 0; color: var(--dashboard-text-primary);"><?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> Cache TTL</h4>
1649 1712 <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">
1650 - 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.
1651 1714 </p>
1652 1715
1653 1716 <div style="background: rgba(255,255,255,0.02); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
1654 1717 <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
@@ -1704,9 +1767,9 @@
1704 1767 } else {
1705 1768 echo '<p style="color: var(--dashboard-text-secondary);">ℹ️ No cache plugins detected.</p>';
1706 1769 }
1707 1770 } catch (Exception $e) {
1708 - 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
1709 1772 echo '<p style="color: var(--dashboard-error);">⚠️ An error occurred while retrieving cache plugin status.</p>';
1710 1773 }
1711 1774 } else {
1712 1775 echo '<p style="color: var(--dashboard-error);">⚠️ Cache Purge class not loaded.</p>';
@@ -1723,12 +1786,12 @@
1723 1786 </form>
1724 1787
1725 1788 <?php
1726 1789 // Display success/error messages
1727 - if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') {
1728 - $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0;
1729 - $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0;
1730 - $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
1731 1794
1732 1795 if ($cleared > 0) {
1733 1796 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1734 1797 echo '✅ <strong>Success!</strong> Cleared cache for ' . intval( $cleared ) . ' plugin(s)';
@@ -1748,10 +1811,10 @@
1748 1811 echo '</p></div>';
1749 1812 }
1750 1813 }
1751 1814
1752 - if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') {
1753 - $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
1754 1817 if (empty($message)) {
1755 1818 $message = 'An unknown error occurred while clearing cache. Please check error logs for details.';
1756 1819 }
1757 1820 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
@@ -1848,12 +1911,13 @@
1848 1911 </form>
1849 1912
1850 1913 <?php
1851 1914 // Hosting cache result messages
1852 - if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') {
1853 - $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode($_GET['hc_cleared'])) : '';
1854 - $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode($_GET['hc_failed'])) : '';
1855 - $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
1856 1920
1857 1921 if ($hc_cleared) {
1858 1922 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1859 1923 echo '✅ <strong>Success!</strong> Purged hosting cache on: ' . esc_html($hc_cleared);
@@ -1868,8 +1932,9 @@
1868 1932 echo '<div class="notice notice-info inline" style="margin-top: 10px;"><p>';
1869 1933 echo 'ℹ️ No enabled hosting providers were detected on this server (' . esc_html($hc_not_detected) . ').';
1870 1934 echo '</p></div>';
1871 1935 }
1936 + // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1872 1937 }
1873 1938 ?>
1874 1939
1875 1940 <script>
@@ -2034,11 +2099,11 @@
2034 2099 </div>
2035 2100
2036 2101 <?php
2037 2102 // Display success/error messages
2038 - if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') {
2039 - $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0;
2040 - $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
2041 2106
2042 2107 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
2043 2108 if (!empty($url)) {
2044 2109 echo '✅ <strong>Success!</strong> Cleared cache for URL: <code>' . esc_html($url) . '</code> (' . intval( $cleared_count ) . ' entries)';
@@ -2047,10 +2112,10 @@
2047 2112 }
2048 2113 echo '</p></div>';
2049 2114 }
2050 2115
2051 - if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') {
2052 - $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
2053 2118 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
2054 2119 echo '❌ <strong>Error:</strong> ' . esc_html($message);
2055 2120 echo '</p></div>';
2056 2121 }
@@ -2089,9 +2154,9 @@
2089 2154 <td>
2090 2155 <input type="url"
2091 2156 id="otto_cache_url"
2092 2157 name="otto_cache_url"
2093 - 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 ?>"
2094 2159 class="regular-text"
2095 2160 placeholder="https://example.com/page/"
2096 2161 required />
2097 2162 <p class="description" style="color: var(--dashboard-text-secondary);">Enter the full URL of the page whose cache you want to clear.</p>
@@ -2162,9 +2227,10 @@
2162 2227 /**
2163 2228 * AJAX handler for saving hosting cache settings
2164 2229 */
2165 2230 public function ajax_save_hosting_cache_settings() {
2166 - 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')) {
2167 2233 wp_send_json_error(array('message' => 'Security check failed'));
2168 2234 return;
2169 2235 }
2170 2236
@@ -2185,9 +2251,10 @@
2185 2251 /**
2186 2252 * AJAX handler: save object cache behaviour settings
2187 2253 */
2188 2254 public function ajax_save_object_cache_settings() {
2189 - 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')) {
2190 2257 wp_send_json_error(array('message' => 'Security check failed'));
2191 2258 return;
2192 2259 }
2193 2260
@@ -2214,9 +2281,10 @@
2214 2281 /**
2215 2282 * AJAX handler for saving OTTO Cache TTL
2216 2283 */
2217 2284 public function ajax_save_otto_cache_ttl() {
2218 - 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')) {
2219 2287 wp_send_json_error(array('message' => 'Security check failed.'));
2220 2288 return;
2221 2289 }
2222 2290
@@ -2242,9 +2310,10 @@
2242 2310 /**
2243 2311 * admin_post handler: purge WP Engine and Kinsta hosting-level caches
2244 2312 */
2245 2313 public function handle_purge_hosting_cache() {
2246 - 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')) {
2247 2316 wp_die('Security check failed');
2248 2317 }
2249 2318
2250 2319 if (!Metasync::current_user_has_plugin_access()) {
@@ -2264,9 +2333,9 @@
2264 2333 WpeCommon::purge_varnish_cache();
2265 2334 WpeCommon::purge_memcached();
2266 2335 $cleared[] = 'WP Engine';
2267 2336 } catch (Exception $e) {
2268 - 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
2269 2338 $failed[] = 'WP Engine';
2270 2339 }
2271 2340 } else {
2272 2341 $not_detected[] = 'WP Engine';
@@ -2279,9 +2348,9 @@
2279 2348 try {
2280 2349 KinstaCache::get_instance()->kinsta_cache_purge_full();
2281 2350 $cleared[] = 'Kinsta';
2282 2351 } catch (Exception $e) {
2283 - 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
2284 2353 $failed[] = 'Kinsta';
2285 2354 }
2286 2355 } else {
2287 2356 $not_detected[] = 'Kinsta';
@@ -2676,9 +2745,9 @@
2676 2745 # Initialize MetaSync API request class and trigger synchronization
2677 2746 (new Metasync_Sync_Requests())->SyncCustomerParams();
2678 2747 } catch (Exception $e) {
2679 2748 # Log any API request errors for debugging
2680 - 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
2681 2750 }
2682 2751 }
2683 2752
2684 2753 /*
@@ -2693,9 +2762,9 @@
2693 2762 # Initialize MetaSync API request class and trigger synchronization
2694 2763 (new Metasync_Sync_Requests())->SyncCustomerParams();
2695 2764 } catch (Exception $e) {
2696 2765 # Log any API request errors for debugging
2697 - 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
2698 2767 }
2699 2768 }
2700 2769
2701 2770 /**
@@ -2728,8 +2797,15 @@
2728 2797 // Handle form submissions
2729 2798 if (isset($_POST['metasync_media_optimization_nonce'])) {
2730 2799 check_admin_referer('metasync_save_media_optimization', 'metasync_media_optimization_nonce');
2731 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 +
2732 2808 // Handle reset to defaults
2733 2809 if (!empty($_POST['metasync_media_reset'])) {
2734 2810 $defaults = Metasync_Media_Settings::get_defaults();
2735 2811 Metasync_Media_Settings::save_settings($defaults);
@@ -2734,8 +2810,9 @@
2734 2810 $defaults = Metasync_Media_Settings::get_defaults();
2735 2811 Metasync_Media_Settings::save_settings($defaults);
2736 2812 $save_success = true;
2737 2813 } elseif (isset($_POST['metasync_media'])) {
2814 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Media_Settings::save_settings().
2738 2815 $input = wp_unslash($_POST['metasync_media']);
2739 2816 Metasync_Media_Settings::save_settings($input);
2740 2817 $save_success = true;
2741 2818 }
@@ -2767,8 +2844,15 @@
2767 2844 * Code Minification page callback
2768 2845 */
2769 2846 public function create_admin_code_minification_page()
2770 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 +
2771 2855 $this->render_layout_open('Code Minification', 'code_minification', 'Minify CSS, JavaScript, and HTML to improve performance.');
2772 2856 // Load settings and compatibility classes
2773 2857 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-settings.php';
2774 2858 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-cache.php';
@@ -2785,8 +2869,9 @@
2785 2869 $defaults = Metasync_Minification_Settings::get_defaults();
2786 2870 Metasync_Minification_Settings::save_settings($defaults);
2787 2871 $save_success = true;
2788 2872 } elseif (isset($_POST['metasync_code_min'])) {
2873 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Minification_Settings::save_settings().
2789 2874 $input = (array) wp_unslash($_POST['metasync_code_min']);
2790 2875 Metasync_Minification_Settings::save_settings($input);
2791 2876 $save_success = true;
2792 2877 }
@@ -2819,8 +2904,15 @@
2819 2904 if (!$attachment_id) {
2820 2905 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2821 2906 }
2822 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 +
2823 2915 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-settings.php';
2824 2916 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-image-converter.php';
2825 2917 $settings = Metasync_Media_Settings::get_settings();
2826 2918
@@ -2834,8 +2926,9 @@
2834 2926 $max_bytes = 10 * 1024 * 1024;
2835 2927 if (filesize($file) > $max_bytes) {
2836 2928 $size_mb = round(filesize($file) / 1024 / 1024, 1);
2837 2929 wp_send_json_error(sprintf(
2930 + /* translators: %s: file size in megabytes. */
2838 2931 __('Optimization skipped: file size (%s MB) exceeds the 10 MB safety limit to prevent memory issues.', 'metasync'),
2839 2932 $size_mb
2840 2933 ));
2841 2934 }
@@ -2872,8 +2965,14 @@
2872 2965 if (!$attachment_id) {
2873 2966 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2874 2967 }
2875 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 +
2876 2975 // Replace-strategy conversions have no original to restore — reverting
2877 2976 // would delete the attachment's only file. Refuse up front, like bulk revert does.
2878 2977 if (!Metasync_Image_Converter::can_revert($attachment_id)) {
2879 2978 wp_send_json_error(__('This image cannot be reverted. The original file no longer exists (replace strategy).', 'metasync'));
@@ -2931,8 +3030,15 @@
2931 3030 public function ajax_batch_progress()
2932 3031 {
2933 3032 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
2934 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 +
2935 3041 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-batch-optimizer.php';
2936 3042 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
2937 3043
2938 3044 $progress = Metasync_Media_Batch_Optimizer::get_progress();
@@ -2963,10 +3069,19 @@
2963 3069 $settings = Metasync_Media_Settings::get_settings();
2964 3070
2965 3071 $success = 0;
2966 3072 $failed = 0;
3073 + $denied = 0;
2967 3074
2968 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 +
2969 3084 if (Metasync_Image_Converter::convert_attachment($id, $settings)) {
2970 3085 $success++;
2971 3086 } else {
2972 3087 $failed++;
@@ -2975,8 +3090,9 @@
2975 3090
2976 3091 wp_send_json_success([
2977 3092 'success' => $success,
2978 3093 'failed' => $failed,
3094 + 'denied' => $denied,
2979 3095 ]);
2980 3096 }
2981 3097
2982 3098 /**
@@ -2999,11 +3115,19 @@
2999 3115
3000 3116 $success = 0;
3001 3117 $failed = 0;
3002 3118 $skipped = 0;
3119 + $denied = 0;
3003 3120 $errors = [];
3004 3121
3005 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 +
3006 3130 $format = get_post_meta($id, '_metasync_converted_format', true);
3007 3131
3008 3132 if (!$format) {
3009 3133 $skipped++;
@@ -3013,8 +3137,9 @@
3013 3137 if (!Metasync_Image_Converter::can_revert($id)) {
3014 3138 $skipped++;
3015 3139 $file = get_attached_file($id);
3016 3140 $name = $file ? basename($file) : "ID {$id}";
3141 + /* translators: %s: attachment file name. */
3017 3142 $errors[] = sprintf(__('%s: skipped — original image unavailable.', 'metasync'), $name);
3018 3143 continue;
3019 3144 }
3020 3145
@@ -3023,8 +3148,9 @@
3023 3148 } else {
3024 3149 $failed++;
3025 3150 $file = get_attached_file($id);
3026 3151 $name = $file ? basename($file) : "ID {$id}";
3152 + /* translators: %s: attachment file name. */
3027 3153 $errors[] = sprintf(__('%s: revert failed (original file may not exist).', 'metasync'), $name);
3028 3154 }
3029 3155 }
3030 3156
@@ -3031,8 +3157,9 @@
3031 3157 wp_send_json_success([
3032 3158 'success' => $success,
3033 3159 'failed' => $failed,
3034 3160 'skipped' => $skipped,
3161 + 'denied' => $denied,
3035 3162 'errors' => $errors,
3036 3163 ]);
3037 3164 }
3038 3165
@@ -3108,8 +3235,86 @@
3108 3235 Metasync_Media_Batch_Optimizer::process_batch_tick();
3109 3236 }
3110 3237
3111 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 + /**
3112 3317 * Report Issue page callback
3113 3318 */
3114 3319 public function create_admin_report_issue_page()
3115 3320 {
@@ -3121,8 +3326,17 @@
3121 3326 * XML Sitemap page callback
3122 3327 */
3123 3328 public function create_admin_xml_sitemap_page()
3124 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 +
3125 3339 // Load sitemap generator class
3126 3340 require_once plugin_dir_path(dirname(__FILE__)) . 'sitemap/class-metasync-sitemap-generator.php';
3127 3341
3128 3342 $sitemap_generator = new Metasync_Sitemap_Generator();
@@ -3150,13 +3364,16 @@
3150 3364
3151 3365 update_option('metasync_sitemap_settings', $sitemap_settings);
3152 3366
3153 3367 // Regenerate sitemap with new content settings
3154 - $result = $sitemap_generator->generate_sitemap();
3368 + $result = $sitemap_generator->generate_sitemap( true );
3155 3369 if (is_wp_error($result)) {
3156 3370 echo '<div class="notice notice-error"><p>' . esc_html(
3371 + /* translators: %s: error message. */
3157 3372 sprintf(__('Settings saved but sitemap generation failed: %s', 'metasync'), $result->get_error_message())
3158 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>';
3159 3376 } else {
3160 3377 echo '<div class="notice notice-success"><p>' . esc_html__('Sitemap content settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3161 3378 }
3162 3379 }
@@ -3167,10 +3384,10 @@
3167 3384
3168 3385 // Build generic taxonomy filters
3169 3386 $news_taxonomies = [];
3170 3387 if (!empty($_POST['news_taxonomies']) && is_array($_POST['news_taxonomies'])) {
3171 - foreach ($_POST['news_taxonomies'] as $tax_name => $term_ids) {
3172 - $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));
3173 3390 }
3174 3391 }
3175 3392
3176 3393 $news_settings = [
@@ -3195,9 +3412,9 @@
3195 3412 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3196 3413 } else {
3197 3414 // Also remove physical file if it exists
3198 3415 if (file_exists(ABSPATH . 'news-sitemap.xml')) {
3199 - @unlink(ABSPATH . 'news-sitemap.xml');
3416 + @wp_delete_file(ABSPATH . 'news-sitemap.xml');
3200 3417 }
3201 3418 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
3202 3419 }
3203 3420 }
@@ -3208,10 +3425,10 @@
3208 3425
3209 3426 // Build generic taxonomy filters
3210 3427 $video_taxonomies = [];
3211 3428 if (!empty($_POST['video_taxonomies']) && is_array($_POST['video_taxonomies'])) {
3212 - foreach ($_POST['video_taxonomies'] as $tax_name => $term_ids) {
3213 - $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));
3214 3431 }
3215 3432 }
3216 3433
3217 3434 $video_settings = [
@@ -3219,8 +3436,12 @@
3219 3436 'post_types' => array_map('sanitize_key', (array) ($_POST['video_post_types'] ?? ['post', 'page'])),
3220 3437 'auto_detect' => isset($_POST['auto_detect']),
3221 3438 'taxonomies' => $video_taxonomies,
3222 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'] ?? '')),
3223 3444 ];
3224 3445
3225 3446 // Always invalidate old cache before saving new settings
3226 3447 delete_transient('metasync_vsm_' . md5('video-sitemap.xml'));
@@ -3233,9 +3454,9 @@
3233 3454 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3234 3455 } else {
3235 3456 // Also remove physical file if it exists
3236 3457 if (file_exists(ABSPATH . 'video-sitemap.xml')) {
3237 - @unlink(ABSPATH . 'video-sitemap.xml');
3458 + @wp_delete_file(ABSPATH . 'video-sitemap.xml');
3238 3459 }
3239 3460 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
3240 3461 }
3241 3462 }
@@ -3299,20 +3520,24 @@
3299 3520 gc_collect_cycles();
3300 3521 }
3301 3522
3302 3523 // Generate main sitemap (its index will include news/video since they now exist)
3303 - $result = $sitemap_generator->generate_sitemap();
3524 + $result = $sitemap_generator->generate_sitemap( true );
3304 3525
3305 3526 if (is_wp_error($result)) {
3306 3527 $error_msg = $result->get_error_message();
3307 - 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
3308 3529 echo '<div class="notice notice-error"><p>' . esc_html(
3530 + /* translators: %s: error message. */
3309 3531 sprintf(__('Sitemap generation failed: %s', 'metasync'), $error_msg)
3310 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>';
3311 3535 } else {
3312 3536 $message = esc_html__('Sitemap generated successfully!', 'metasync');
3313 3537 if (!empty($extras)) {
3314 3538 $message .= ' ' . sprintf(
3539 + /* translators: %s: list of sitemap types. */
3315 3540 esc_html__('Also generated %s sitemap(s).', 'metasync'),
3316 3541 implode(' & ', $extras)
3317 3542 );
3318 3543 }
@@ -3501,47 +3726,8 @@
3501 3726 Metasync_Admin_Pages::get_instance($this)->create_admin_breadcrumbs_page();
3502 3727 }
3503 3728
3504 3729 /**
3505 - * Google Instant Index Setting page callback
3506 - */
3507 - public function create_admin_google_instant_index_page()
3508 - {
3509 - $this->render_layout_open('Instant Indexing', 'instant_index', 'Submit URLs to Google for instant indexing via the Indexing API.');
3510 -
3511 - // Render shared Google Index credentials section
3512 - if (!function_exists('google_index_direct')) {
3513 - if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) {
3514 - require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
3515 - } else {
3516 - error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php');
3517 - return;
3518 - }
3519 - }
3520 - $google_index = google_index_direct();
3521 - $service_info = $google_index->get_service_account_info();
3522 - $is_configured = !isset($service_info['error']);
3523 -
3524 - $saved_json_display = $is_configured ? $google_index->get_redacted_config_json() : '';
3525 -
3526 - include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-index-api-settings.php';
3527 -
3528 - // Render post types selection with save form
3529 - $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
3530 - $post_types_settings = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
3531 - ?>
3532 - <form method="POST" action="">
3533 - <?php include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-instant-post-types.php'; ?>
3534 - <div class="dashboard-card" style="padding: 20px;">
3535 - <?php submit_button('Save Post Types', 'primary', 'submit', false, array('class' => 'button button-primary')); ?>
3536 - </div>
3537 - </form>
3538 - <?php
3539 -
3540 - $this->render_layout_close();
3541 - }
3542 -
3543 - /**
3544 3730 * Google Console page callback
3545 3731 */
3546 3732 public function create_admin_google_console_page()
3547 3733 {
@@ -3647,8 +3833,9 @@
3647 3833 if (!get_transient('metasync_llms_conflict')) {
3648 3834 return;
3649 3835 }
3650 3836 echo '<div class="notice notice-info is-dismissible"><p>';
3837 + /* translators: %s: effective plugin name (whitelabel-aware). */
3651 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()));
3652 3839 echo '</p></div>';
3653 3840 }
3654 3841
@@ -3756,9 +3943,9 @@
3756 3943 {
3757 3944 register_rest_route('metasync/v1', '/ping', array(
3758 3945 'methods' => array('GET', 'POST'),
3759 3946 'callback' => array($this, 'handle_ping_rest_endpoint'),
3760 - 'permission_callback' => '__return_true', // Allow public access
3947 + 'permission_callback' => '__return_true',
3761 3948 'args' => array(
3762 3949 'test' => array(
3763 3950 'description' => 'Optional test parameter',
3764 3951 'type' => 'string',
@@ -3840,9 +4027,9 @@
3840 4027 return;
3841 4028 }
3842 4029
3843 4030 // Get pagination parameters
3844 - $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
4031 + $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
3845 4032 $per_page = Metasync_Per_Page_Helper::resolve('sync_log', 10);
3846 4033 $offset = ($page - 1) * $per_page;
3847 4034
3848 4035 // Get filters
@@ -3847,10 +4034,10 @@
3847 4034
3848 4035 // Get filters
3849 4036 $filters = [
3850 4037 // UI exposes date_range and status only. We compute date_from/date_to based on date_range
3851 - 'date_range' => isset($_GET['date_range']) ? sanitize_text_field(wp_unslash($_GET['date_range'])) : '',
3852 - '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
3853 4040 ];
3854 4041
3855 4042 // Map date_range to concrete date_from/date_to for DB queries
3856 4043 $date_range = $filters['date_range'];
@@ -3859,28 +4046,28 @@
3859 4046 $date_to = '';
3860 4047
3861 4048 if (!empty($date_range)) {
3862 4049 // End boundary is now by default
3863 - $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);
3864 4051
3865 4052 if ($date_range === 'today') {
3866 4053 $start_ts = strtotime('today', $wp_now_ts);
3867 - $date_from = date('Y-m-d H:i:s', $start_ts);
4054 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3868 4055 } elseif ($date_range === 'yesterday') {
3869 4056 $start_ts = strtotime('yesterday', $wp_now_ts);
3870 4057 $end_ts = strtotime('today', $wp_now_ts) - 1; // end of yesterday
3871 - $date_from = date('Y-m-d H:i:s', $start_ts);
3872 - $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);
3873 4060 } elseif ($date_range === 'this_week') {
3874 4061 $start_of_week = (int) get_option('start_of_week', 1); // 0=Sun, 1=Mon
3875 - $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
3876 4063 // Convert start_of_week to PHP's 0..6 where 0=Sunday
3877 4064 $delta_days = ($day_of_week - $start_of_week + 7) % 7;
3878 4065 $start_ts = strtotime('-' . $delta_days . ' days', strtotime('today', $wp_now_ts));
3879 - $date_from = date('Y-m-d H:i:s', $start_ts);
4066 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3880 4067 } elseif ($date_range === 'this_month') {
3881 - $start_ts = strtotime(date('Y-m-01 00:00:00', $wp_now_ts));
3882 - $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);
3883 4070 } elseif ($date_range === 'all') {
3884 4071 // no bounds
3885 4072 }
3886 4073 }
@@ -3907,12 +4094,24 @@
3907 4094 ?>
3908 4095 <div class="dashboard-card">
3909 4096 <div class="sync-log-header">
3910 4097 <div class="sync-log-title-section">
3911 - <h2>Changes Log</h2>
3912 - <p style="color: var(--dashboard-text-secondary); margin-bottom: 0;">
3913 - Recent content synchronizations from external tools.
3914 - <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>
3915 4114 </p>
3916 4115 </div>
3917 4116
3918 4117 <!-- Filters + Clear Log button - Right aligned -->
@@ -3922,9 +4121,9 @@
3922 4121 data-nonce="<?php echo esc_attr(wp_create_nonce('metasync_clear_sync_log')); ?>">
3923 4122 🗑 Clear Log
3924 4123 </button>
3925 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;">
3926 - <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 ?>">
3927 4126
3928 4127 <select name="date_range" class="sync-filter-select">
3929 4128 <option value="all" <?php selected($filters['date_range'] ?? 'all', 'all'); ?>> All Time</option>
3930 4129 <option value="today" <?php selected($filters['date_range'] ?? '', 'today'); ?>>Today</option>
@@ -3946,9 +4145,9 @@
3946 4145 <div class="sync-log-list">
3947 4146 <?php if (empty($sync_records)): ?>
3948 4147 <div class="sync-log-empty">
3949 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>
3950 - <h3>No sync records found</h3>
4149 + <h2>No sync records found</h2>
3951 4150 <p>Sync records will appear here when content/pages receive new updates.</p>
3952 4151 </div>
3953 4152 <?php else: ?>
3954 4153 <?php foreach ($sync_records as $record): ?>
@@ -4005,24 +4204,25 @@
4005 4204 <!-- Pagination -->
4006 4205 <div class="sync-log-pagination">
4007 4206 <div class="sync-log-pagination-info">
4008 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; ?>
4009 - <?php echo Metasync_Per_Page_Helper::render_selector('sync_log', $per_page); ?>
4208 + <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper returns a complete escaped selector element.
4209 + echo Metasync_Per_Page_Helper::render_selector('sync_log', absint($per_page)); ?>
4010 4210 </div>
4011 4211
4012 4212 <?php if ($total_pages > 1): ?>
4013 4213 <div class="sync-log-pagination-controls">
4014 4214 <?php if ($page > 1): ?>
4015 - <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>
4016 4216 <?php endif; ?>
4017 4217
4018 4218 <?php for ($i = max(1, $page - 2); $i <= min($total_pages, $page + 2); $i++): ?>
4019 - <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) ); ?>"
4020 4220 class="sync-pagination-btn <?php echo $i === $page ? 'active' : ''; ?>"><?php echo intval( $i ); ?></a>
4021 4221 <?php endfor; ?>
4022 4222
4023 4223 <?php if ($page < $total_pages): ?>
4024 - <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>
4025 4225 <?php endif; ?>
4026 4226 </div>
4027 4227 <?php endif; ?>
4028 4228 </div>
@@ -4111,10 +4311,10 @@
4111 4311
4112 4312 // Preserve the user-selected results-per-page value across page
4113 4313 // navigation so a non-default page size survives clicking a page link.
4114 4314 $per_page_key = Metasync_Per_Page_Helper::request_key('sync_log');
4115 - if (isset($_GET[$per_page_key])) {
4116 - $per_page = (int) $_GET[$per_page_key];
4315 + if (isset($_GET[$per_page_key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4316 + $per_page = (int) $_GET[$per_page_key]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4117 4317 if (in_array($per_page, Metasync_Per_Page_Helper::allowed_values(), true)) {
4118 4318 $query_parts[] = $per_page_key . '=' . $per_page;
4119 4319 }
4120 4320 }
@@ -4293,18 +4493,8 @@
4293 4493 {
4294 4494 Metasync_Connect_Manager::instance()->handle_session_management_early();
4295 4495 }
4296 4496
4297 - /**
4298 - * @deprecated 2.5.12 Use Metasync_Auth_Manager instead of sessions for authentication
4299 - */
4300 - private function safe_session_start() {
4301 - // This method is deprecated and no longer used
4302 - // Authentication now uses Metasync_Auth_Manager with WordPress transients and user meta
4303 - _deprecated_function(__METHOD__, '2.5.12', 'Metasync_Auth_Manager');
4304 - return Metasync_Session_Helper::safe_start();
4305 - }
4306 -
4307 4497 private function handle_whitelabel_session_logic()
4308 4498 {
4309 4499 Metasync_Connect_Manager::instance()->handle_whitelabel_session_logic();
4310 4500 }
@@ -4400,9 +4590,9 @@
4400 4590 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4401 4591 Effective Threshold
4402 4592 </label>
4403 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);">
4404 - <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>
4405 4595 </div>
4406 4596 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
4407 4597 Calculated as: cores × per-core threshold
4408 4598 </p>
@@ -4420,13 +4610,13 @@
4420 4610 <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo intval($stats['deferrals']); ?></div>
4421 4611 </div>
4422 4612 <div>
4423 4613 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Max Load Observed</div>
4424 - <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>
4425 4615 </div>
4426 4616 <div>
4427 4617 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Average Load</div>
4428 - <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>
4429 4619 </div>
4430 4620 </div>
4431 4621 </div>
4432 4622
@@ -5131,8 +5321,14 @@
5131 5321 * Show a one-time admin notice when a page builder is detected but the
5132 5322 * "Default Page Builder" setting has never been explicitly saved.
5133 5323 */
5134 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 +
5135 5331 $configured = Metasync::get_option('general')['default_page_builder'] ?? '';
5136 5332
5137 5333 // Setting already saved — nothing to warn about
5138 5334 if (!empty($configured)) {
@@ -5145,9 +5341,9 @@
5145 5341 return;
5146 5342 }
5147 5343
5148 5344 // Handle dismiss action
5149 - 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')) {
5150 5346 update_user_meta(get_current_user_id(), 'metasync_builder_notice_dismissed', '1');
5151 5347 return;
5152 5348 }
5153 5349
@@ -5242,9 +5438,10 @@
5242 5438 * Saves the CPU load threshold and returns statistics
5243 5439 */
5244 5440 public function ajax_save_performance_settings() {
5245 5441 # Check nonce for security and return early if invalid
5246 - 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')) {
5247 5444 wp_send_json_error(array('message' => 'Invalid nonce'));
5248 5445 return;
5249 5446 }
5250 5447
@@ -5312,9 +5509,9 @@
5312 5509 if (!wp_next_scheduled('metasync_cleanup_transients')) {
5313 5510 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_cleanup_transients');
5314 5511
5315 5512 if (!$scheduled) {
5316 - 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
5317 5514 }
5318 5515 }
5319 5516 }
5320 5517
@@ -5325,9 +5522,9 @@
5325 5522 {
5326 5523 $timestamp = wp_next_scheduled('metasync_cleanup_transients');
5327 5524 if ($timestamp) {
5328 5525 wp_unschedule_event($timestamp, 'metasync_cleanup_transients');
5329 - 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
5330 5527 }
5331 5528 }
5332 5529
5333 5530 /**
@@ -5349,12 +5546,10 @@
5349 5546 {
5350 5547 if (!wp_next_scheduled('metasync_hidden_post_check')) {
5351 5548 $scheduled = wp_schedule_event(time(), 'metasync_weekly', 'metasync_hidden_post_check');
5352 5549
5353 - if ($scheduled) {
5354 - error_log('MetaSync: Hidden post manager cron job scheduled successfully (runs every 7 days)');
5355 - } else {
5356 - 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
5357 5552 }
5358 5553 }
5359 5554 }
5360 5555
@@ -5365,12 +5560,10 @@
5365 5560 public function maybe_schedule_otto_recheck_404_cron()
5366 5561 {
5367 5562 if (!wp_next_scheduled('metasync_otto_recheck_404_exclusions')) {
5368 5563 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_otto_recheck_404_exclusions');
5369 - if ($scheduled) {
5370 - error_log('MetaSync: OTTO 404 recheck cron job scheduled successfully (runs daily)');
5371 - } else {
5372 - 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
5373 5566 }
5374 5567 }
5375 5568 }
5376 5569
@@ -5443,13 +5636,13 @@
5443 5636
5444 5637 try {
5445 5638 // 1. Post revisions
5446 5639 if (!empty($settings['clean_post_revisions'])) {
5447 - $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
5448 5641 "DELETE FROM {$wpdb->posts} WHERE post_type = 'revision'"
5449 5642 );
5450 5643 // Remove postmeta left behind by deleted revisions
5451 - $wpdb->query(
5644 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5452 5645 "DELETE pm FROM {$wpdb->postmeta} pm
5453 5646 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5454 5647 WHERE p.ID IS NULL"
5455 5648 );
@@ -5457,20 +5650,19 @@
5457 5650
5458 5651 // 2. Trashed posts + their postmeta
5459 5652 if (!empty($settings['clean_trashed_posts'])) {
5460 5653 // Collect IDs first to cleanly remove postmeta
5461 - $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
5462 5655 "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'trash'"
5463 5656 );
5464 5657 if (!empty($trashed_ids)) {
5465 - $placeholders = implode(',', array_fill(0, count($trashed_ids), '%d'));
5466 - $wpdb->query(
5658 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5467 5659 $wpdb->prepare(
5468 - "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')) . ')',
5469 5661 $trashed_ids
5470 5662 )
5471 5663 );
5472 - $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
5473 5665 "DELETE FROM {$wpdb->posts} WHERE post_status = 'trash'"
5474 5666 );
5475 5667 } else {
5476 5668 $stats['trashed_posts'] = 0;
@@ -5478,9 +5670,9 @@
5478 5670 }
5479 5671
5480 5672 // 3. Trashed comments
5481 5673 if (!empty($settings['clean_trashed_comments'])) {
5482 - $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
5483 5675 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'trash'"
5484 5676 );
5485 5677 }
5486 5678
@@ -5485,9 +5677,9 @@
5485 5677 }
5486 5678
5487 5679 // 4. Spam comments
5488 5680 if (!empty($settings['clean_spam_comments'])) {
5489 - $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
5490 5682 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'spam'"
5491 5683 );
5492 5684 }
5493 5685
@@ -5493,15 +5685,15 @@
5493 5685
5494 5686 // 5. Expired transients — direct SQL, no cache flush
5495 5687 if (!empty($settings['clean_expired_transients'])) {
5496 5688 // Delete timeout rows that have already expired
5497 - $wpdb->query(
5689 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5498 5690 "DELETE FROM {$wpdb->options}
5499 5691 WHERE option_name LIKE '\_transient\_timeout\_%'
5500 5692 AND option_value + 0 < UNIX_TIMESTAMP()"
5501 5693 );
5502 5694 // Delete value rows whose timeout row no longer exists
5503 - $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
5504 5696 "DELETE o FROM {$wpdb->options} o
5505 5697 LEFT JOIN {$wpdb->options} t
5506 5698 ON t.option_name = CONCAT('_transient_timeout_', SUBSTRING(o.option_name, 12))
5507 5699 WHERE o.option_name LIKE '\_transient\_%'
@@ -5511,9 +5703,9 @@
5511 5703 }
5512 5704
5513 5705 // 6. Orphaned postmeta (post_id references a post that no longer exists)
5514 5706 if (!empty($settings['clean_orphaned_postmeta'])) {
5515 - $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
5516 5708 "DELETE pm FROM {$wpdb->postmeta} pm
5517 5709 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5518 5710 WHERE p.ID IS NULL"
5519 5711 );
@@ -5525,12 +5717,10 @@
5525 5717 $settings['last_run_at'] = time();
5526 5718 $settings['last_run_stats'] = $stats;
5527 5719 update_option('metasync_db_cleanup_settings', $settings);
5528 5720
5529 - error_log('MetaSync: DB cleanup completed — ' . json_encode($stats));
5530 -
5531 5721 } catch (Exception $e) {
5532 - 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
5533 5723 }
5534 5724 }
5535 5725
5536 5726 /**
@@ -5747,10 +5937,11 @@
5747 5937 /**
5748 5938 * AJAX: Save DB cleanup settings and reschedule cron accordingly.
5749 5939 */
5750 5940 public function ajax_save_db_cleanup_settings() {
5941 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5751 5942 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5752 - !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')) {
5753 5944 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
5754 5945 return;
5755 5946 }
5756 5947
@@ -5807,10 +5998,11 @@
5807 5998 * AJAX: Manually trigger the DB cleanup and return stats for the UI.
5808 5999 * Persists current form state first so unsaved checkbox changes are respected.
5809 6000 */
5810 6001 public function ajax_run_db_cleanup() {
6002 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5811 6003 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5812 - !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')) {
5813 6005 wp_send_json_error(array('message' => 'Invalid security token.'));
5814 6006 return;
5815 6007 }
5816 6008
@@ -5886,35 +6078,8 @@
5886 6078 ));
5887 6079 }
5888 6080
5889 6081 /**
5890 - * Control plugin auto-updates based on user setting
5891 - *
5892 - * @param bool $update Whether to update
5893 - * @param object $item Update offer
5894 - * @return bool Whether to allow auto-update
5895 - */
5896 - public function control_plugin_auto_updates($update, $item)
5897 - {
5898 - # Check if the item object has the slug property
5899 - if (!isset($item->slug)) {
5900 - return $update;
5901 - }
5902 -
5903 - // Check if this is our plugin
5904 - if ($item->slug === 'metasync') {
5905 - $general_settings = Metasync::get_option('general') ?? [];
5906 - $enable_auto_updates = $general_settings['enable_auto_updates'] ?? false;
5907 -
5908 - // Return the user's preference (true = allow auto-updates, false = prevent)
5909 - return $enable_auto_updates === 'true' || $enable_auto_updates === true;
5910 - }
5911 -
5912 - // For other plugins, don't interfere with their auto-update settings
5913 - return $update;
5914 - }
5915 -
5916 - /**
5917 6082 * AJAX handler to add excluded URL for OTTO
5918 6083 */
5919 6084 public function ajax_otto_add_excluded_url()
5920 6085 {
@@ -6060,8 +6225,9 @@
6060 6225 // If page has raw HTML or custom CSS from conversion, show badge
6061 6226 if ($has_raw_html || !empty($has_custom_css)) {
6062 6227 $label = $this->get_html_source_label();
6063 6228 $tooltip = sprintf(
6229 + /* translators: %s: HTML source label. */
6064 6230 __('This page was created using %s HTML-to-Builder converter', 'metasync'),
6065 6231 $label
6066 6232 );
6067 6233
@@ -6114,8 +6280,9 @@
6114 6280
6115 6281 if ($has_raw_html || !empty($has_custom_css)) {
6116 6282 $label = $this->get_html_source_label();
6117 6283 $message = sprintf(
6284 + /* translators: %s: HTML source label. */
6118 6285 __('This page was created using %s HTML-to-Builder converter. The design is preserved with custom CSS and inline styles.', 'metasync'),
6119 6286 '<strong>' . esc_html($label) . '</strong>'
6120 6287 );
6121 6288
@@ -6129,9 +6296,9 @@
6129 6296 <p class="metasync-editor-message">%s</p>
6130 6297 </div>
6131 6298 </div>',
6132 6299 esc_html($label),
6133 - $message
6300 + wp_kses($message, array('strong' => array()))
6134 6301 );
6135 6302 }
6136 6303 }
6137 6304
@@ -6154,9 +6321,9 @@
6154 6321 ?>
6155 6322 <fieldset class="inline-edit-col-left metasync-quick-edit-source">
6156 6323 <div class="inline-edit-col">
6157 6324 <label>
6158 - <span class="title"><?php _e('Source', 'metasync'); ?></span>
6325 + <span class="title"><?php esc_html_e('Source', 'metasync'); ?></span>
6159 6326 <span class="metasync-quick-edit-badge-container"></span>
6160 6327 </label>
6161 6328 </div>
6162 6329 </fieldset>
@@ -6168,8 +6335,9 @@
6168 6335 */
6169 6336 public function add_html_pages_dashboard_widget()
6170 6337 {
6171 6338 $label = $this->get_html_source_label();
6339 + /* translators: %s: HTML source label. */
6172 6340 $widget_title = sprintf(__('%s Pages', 'metasync'), $label);
6173 6341
6174 6342 wp_add_dashboard_widget(
6175 6343 'metasync_html_pages_widget',
@@ -6310,34 +6478,8 @@
6310 6478 $bing_instant_index->send();
6311 6479 }
6312 6480
6313 6481 /**
6314 - * Save instant indexing settings (Google and Bing)
6315 - *
6316 - * @since 2.6.0
6317 - * @return void
6318 - */
6319 - public function save_instant_indexing_settings()
6320 - {
6321 - // Check if this is a settings submission
6322 - if (!isset($_POST['submit'])) {
6323 - return;
6324 - }
6325 -
6326 - // Save post types for Google Instant Indexing auto-submit
6327 - if (isset($_POST['metasync_post_types'])) {
6328 - $post_data = metasync_sanitize_input_array($_POST);
6329 - $post_types = is_array($post_data['metasync_post_types']) ? array_map('sanitize_title', $post_data['metasync_post_types']) : [];
6330 -
6331 - $settings = get_option('metasync_options_instant_indexing', ['post_types' => []]);
6332 - $settings['post_types'] = array_values($post_types);
6333 - update_option('metasync_options_instant_indexing', $settings);
6334 - }
6335 -
6336 - // Note: Bing Instant Indexing settings are saved via AJAX in save_bing_inline_settings_ajax()
6337 - }
6338 -
6339 - /**
6340 6482 * Save Bing instant indexing settings from inline form (Indexation Control page)
6341 6483 *
6342 6484 * @since 2.6.0
6343 6485 * @return bool True on success, false on failure
@@ -6407,12 +6549,24 @@
6407 6549 if (!empty($seo_controls['enable_googleinstantindex']) && $seo_controls['enable_googleinstantindex'] === 'true') {
6408 6550 $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
6409 6551 $post_types = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
6410 6552
6411 - if (in_array($post->post_type, $post_types) && function_exists('google_index_direct')) {
6412 - $service_info = google_index_direct()->get_service_account_info();
6413 - if (!isset($service_info['error'])) {
6414 - 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 + }
6415 6569 }
6416 6570 }
6417 6571 }
6418 6572