PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | admin/class-metasync-admin.php +426 -243 2.6.22 → 2.7.1 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
@@ -264,8 +262,9 @@
264 262 add_action('metasync_api_key_changed', array('Metasync_Admin_Navigation', 'invalidate_admin_bar_status_cache'), 10, 0);
265 263
266 264 add_action('admin_init', array($this, 'initialize_cookie'));
267 265 add_action('admin_init', array($this, 'maybe_redirect_to_wizard'));
266 + add_action('admin_init', array($this, 'redirect_legacy_instant_indexing_page'));
268 267
269 268 // Add admin_post hooks for form submissions (WordPress standard way - no output buffering needed)
270 269 add_action('admin_post_metasync_clear_all_cache_plugins', array($this, 'handle_clear_all_cache_plugins'));
271 270 add_action('admin_post_metasync_clear_otto_cache_all', array($this, 'handle_clear_otto_cache_all'));
@@ -283,8 +282,11 @@
283 282
284 283 // Add AJAX for saving execution settings
285 284 add_action( 'wp_ajax_metasync_save_execution_settings', array($this, 'ajax_save_execution_settings') );
286 285
286 + // Add AJAX for saving the Headless Mode demo controls on the Advanced tab
287 + add_action( 'wp_ajax_metasync_save_headless_settings', array(Metasync_Settings_Registration::instance(), 'ajax_save_headless_settings') );
288 +
287 289 // Add AJAX for saving hosting cache settings
288 290 add_action( 'wp_ajax_metasync_save_hosting_cache_settings', array($this, 'ajax_save_hosting_cache_settings') );
289 291
290 292 // Add AJAX for saving OTTO Cache TTL
@@ -373,8 +375,15 @@
373 375 add_action('wp_ajax_metasync_process_batch_tick', array($this, 'ajax_process_batch_tick'));
374 376 add_action('wp_ajax_metasync_delete_orphaned_image', array($this, 'ajax_delete_orphaned_image'));
375 377 add_action('metasync_media_batch_optimize_cron', array($this, 'handle_media_batch_cron'));
376 378
379 + # SEO Restore (Rollback) AJAX & Cron handlers
380 + add_action('wp_ajax_metasync_seo_restore_start', array($this, 'ajax_seo_restore_start'));
381 + add_action('wp_ajax_metasync_seo_restore_cancel', array($this, 'ajax_seo_restore_cancel'));
382 + add_action('wp_ajax_metasync_seo_restore_progress', array($this, 'ajax_seo_restore_progress'));
383 + add_action('wp_ajax_metasync_seo_restore_process_tick', array($this, 'ajax_seo_restore_process_tick'));
384 + add_action('metasync_seo_restore_cron', array($this, 'handle_seo_restore_cron'));
385 +
377 386 # Add AJAX handlers for Google Instant Indexing
378 387 add_action('wp_ajax_metasync_send_giapi', array($this, 'ajax_send_giapi'));
379 388
380 389 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
@@ -488,9 +497,9 @@
488 497 <?php
489 498 }
490 499
491 500 public function metasync_fouc_prevention_style() {
492 - if ( ! isset( $_GET['page'] ) || strpos( $_GET['page'], self::$page_slug ) !== 0 ) {
501 + if ( ! isset( $_GET['page'] ) || strpos( sanitize_key(wp_unslash($_GET['page'])), self::$page_slug ) !== 0 ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check for an inline style
493 502 return;
494 503 }
495 504 ?>
496 505 <style>
@@ -512,13 +521,13 @@
512 521 <?php
513 522 }
514 523
515 524 public function suppress_notices_on_wizard_page() {
516 - if ( ! isset( $_GET['page'] ) ) {
525 + if ( ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
517 526 return;
518 527 }
519 528
520 - $page = sanitize_text_field( wp_unslash( $_GET['page'] ) );
529 + $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
521 530
522 531 // Setup wizard: strip every notice for a fully focused, distraction-free screen.
523 532 if ( strpos( $page, '-setup-wizard' ) !== false ) {
524 533 remove_all_actions( 'admin_notices' );
@@ -636,9 +645,9 @@
636 645
637 646 #Redirect url to the new slug
638 647 $redirect_url = admin_url('admin.php?page=' . $current_slug);
639 648
640 - wp_redirect($redirect_url);
649 + wp_safe_redirect($redirect_url);
641 650 exit;
642 651 }
643 652 }
644 653
@@ -690,13 +699,13 @@
690 699 * @since 1.0.0
691 700 */
692 701 public function maybe_redirect_to_wizard() {
693 702 // Only redirect if wizard should be shown
694 - if (get_option('metasync_show_wizard') && !isset($_GET['page'])) {
703 + if (get_option('metasync_show_wizard') && !isset($_GET['page'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- isset() routing check for a redirect to a fixed admin URL
695 704 delete_option('metasync_show_wizard');
696 705
697 706 // Don't redirect during AJAX, cron, or bulk activation
698 - if (wp_doing_ajax() || wp_doing_cron() || isset($_GET['activate-multi'])) {
707 + if (wp_doing_ajax() || wp_doing_cron() || isset($_GET['activate-multi'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- isset() routing check for a redirect to a fixed admin URL
699 708 return;
700 709 }
701 710
702 711 // Only redirect if user has access to the plugin
@@ -708,11 +717,56 @@
708 717 exit;
709 718 }
710 719 }
711 720
721 + /**
722 + * Redirect the removed standalone Instant Indexing page to Indexation Control.
723 + *
724 + * The menu entry is gone, but bookmarks and external links can still target
725 + * the old URL. Resolve the current white-label slug rather than assuming
726 + * "searchatlas".
727 + */
728 + public function redirect_legacy_instant_indexing_page() {
729 + if (wp_doing_ajax() || wp_doing_cron() || !is_admin() || !isset($_GET['page'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
730 + return;
731 + }
732 +
733 + $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
734 + $legacy_page = self::$page_slug . '-instant-index';
735 + if ($page !== $legacy_page || !Metasync::current_user_has_plugin_access()) {
736 + return;
737 + }
738 +
739 + // Indexation Control is registered only when access control keeps it
740 + // visible. Redirecting to it blindly would dead-end on an
741 + // unregistered slug (wp_die "Invalid plugin page") for users whose
742 + // white-label config hides that page — the exact configs where the
743 + // removed standalone page was the feature's only UI. The bare plugin
744 + // slug is the floor: its top-level callback resolves to the first
745 + // reachable page, so it is always a safe target.
746 + if (Metasync_Access_Control::user_can_access('hide_indexation_control')) {
747 + $target = admin_url('admin.php?page=' . self::$page_slug . '-seo-controls');
748 + $query = [];
749 + foreach (['tab', 'subtab'] as $key) {
750 + if (isset($_GET[$key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
751 + $query[$key] = sanitize_key(wp_unslash($_GET[$key])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
752 + }
753 + }
754 + if (!empty($query)) {
755 + $target = add_query_arg($query, $target);
756 + }
757 + } else {
758 + $target = admin_url('admin.php?page=' . self::$page_slug);
759 + }
760 +
761 + wp_safe_redirect($target);
762 + exit;
763 + }
764 +
712 765 public function metasync_display_error_log() {
713 766 Metasync_Debug_Manager::instance()->metasync_display_error_log($this);
714 767 }
768 +
715 769 public function metasync_update_wp_config() {
716 770 Metasync_Debug_Manager::instance()->metasync_update_wp_config();
717 771 }
718 772
@@ -755,9 +809,9 @@
755 809 ]);
756 810 $_COOKIE['metasync_previous_slug'] = $new_slug;
757 811 // Redirect to the new slug
758 812 $redirect_url = admin_url('admin.php?page=' . $old_slug);
759 - wp_redirect($redirect_url);
813 + wp_safe_redirect($redirect_url);
760 814 exit;
761 815 }else{
762 816 self::$page_slug = Metasync::get_option('general')['white_label_plugin_menu_slug']=="" ? "searchatlas":Metasync::get_option('general')['white_label_plugin_menu_slug'];
763 817 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
@@ -763,9 +817,9 @@
763 817 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
764 818
765 819 #add redirection for when the old slug is not defined
766 820 #this fixes the redirect issue #
767 - wp_redirect($redirect_url);
821 + wp_safe_redirect($redirect_url);
768 822 exit;
769 823 }
770 824 }
771 825 public function metasync_view_detials_url( $plugin_meta, $plugin_file, $plugin_data ) {
@@ -776,9 +830,10 @@
776 830 if (strpos($meta, 'open-plugin-details-modal') !== false) {
777 831 $meta = sprintf(
778 832 '<a href="%s" class="thickbox open-plugin-details-modal" aria-label="%s" data-title="%s">%s</a>',
779 833 add_query_arg('TB_iframe', 'true', $plugin_uri),
780 - esc_attr(sprintf(esc_html__('More information about %s'), $plugin_data['Name'])),
834 + /* translators: %s: plugin name. */
835 + esc_attr(sprintf(esc_html__('More information about %s', 'metasync'), $plugin_data['Name'])),
781 836 esc_attr($plugin_data['Name']),
782 837 esc_html__('View details', 'metasync')
783 838 );
784 839 break; // Exit loop after replacing the link
@@ -826,53 +881,8 @@
826 881 return $all_plugins;
827 882 }
828 883
829 884 /**
830 - * Inject the whitelabel icon into the update_plugins transient so that
831 - * /wp-admin/update-core.php (Dashboard → Updates) shows the WL icon
832 - * instead of the SearchAtlas icon returned by the update API.
833 - *
834 - * @param object $transient The site transient object.
835 - * @return object
836 - */
837 - public function inject_whitelabel_icon_into_update_transient($transient) {
838 - if (empty($transient) || !is_object($transient)) {
839 - return $transient;
840 - }
841 -
842 - $general = Metasync::get_option('general');
843 - if (!is_array($general)) {
844 - return $transient;
845 - }
846 -
847 - $icon_url = $general['white_label_plugin_menu_icon'] ?? '';
848 - if (empty($icon_url)) {
849 - return $transient;
850 - }
851 -
852 - $this_plugin = plugin_basename(dirname(__DIR__) . '/metasync.php');
853 -
854 - // Inject into pending updates list
855 - if (!empty($transient->response) && isset($transient->response[$this_plugin])) {
856 - $transient->response[$this_plugin]->icons = [
857 - '1x' => $icon_url,
858 - '2x' => $icon_url,
859 - ];
860 - }
861 -
862 - // Also inject into the "no update needed" list so the icon appears
863 - // on the updates screen even when the plugin is up-to-date
864 - if (!empty($transient->no_update) && isset($transient->no_update[$this_plugin])) {
865 - $transient->no_update[$this_plugin]->icons = [
866 - '1x' => $icon_url,
867 - '2x' => $icon_url,
868 - ];
869 - }
870 -
871 - return $transient;
872 - }
873 -
874 - /**
875 885 * Register the stylesheets for the admin area.
876 886 *
877 887 * @since 1.0.0
878 888 */
@@ -877,9 +887,9 @@
877 887 * @since 1.0.0
878 888 */
879 889 public function enqueue_styles()
880 890 {
881 - $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
891 + $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
882 892
883 893 if (strpos($current_page, self::$page_slug) === 0) {
884 894 wp_enqueue_style(
885 895 $this->plugin_name,
@@ -908,9 +918,9 @@
908 918 );
909 919 }
910 920
911 921 // Enqueue wizard CSS if on wizard page
912 - if (isset($_GET['page']) && strpos($_GET['page'], '-setup-wizard') !== false) {
922 + if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-setup-wizard') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
913 923 wp_enqueue_style(
914 924 $this->plugin_name . '-setup-wizard',
915 925 plugin_dir_url(__FILE__) . 'css/metasync-setup-wizard.css',
916 926 array($this->plugin_name . '-dashboard'),
@@ -919,9 +929,9 @@
919 929 );
920 930 }
921 931
922 932 // Enqueue SEO Health CSS if on the SEO Health page
923 - if (isset($_GET['page']) && strpos($_GET['page'], '-seo-health') !== false) {
933 + if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-seo-health') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
924 934 wp_enqueue_style(
925 935 $this->plugin_name . '-seo-health',
926 936 plugin_dir_url(__FILE__) . 'css/metasync-seo-health.css',
927 937 array($this->plugin_name . '-dashboard'),
@@ -938,9 +948,9 @@
938 948 */
939 949 public function enqueue_scripts()
940 950 {
941 951 // --- Phase 5 (#887): Extracted inline JS files ---
942 - $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
952 + $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick scripts
943 953 $plugin_root_url = plugin_dir_url(dirname(__FILE__));
944 954 $is_metasync_page = ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0);
945 955
946 956 if ($is_metasync_page) {
@@ -970,8 +980,19 @@
970 980 array('jquery'),
971 981 $this->version,
972 982 true
973 983 );
984 +
985 + // Keeps core's admin-menu pinning in step with pages whose height
986 + // changes after load. Depends on 'common' so core has bound its
987 + // own handlers before this runs.
988 + wp_enqueue_script(
989 + $this->plugin_name . '-admin-menu-height',
990 + plugin_dir_url(__FILE__) . 'js/metasync-admin-menu-height.js',
991 + array('jquery', 'common'),
992 + $this->version,
993 + true
994 + );
974 995 }
975 996
976 997 $per_page_screens = [
977 998 self::$page_slug . '-redirections',
@@ -990,8 +1011,16 @@
990 1011 array(),
991 1012 $this->version,
992 1013 true
993 1014 );
1015 + // Selector navigation is a GET, so the per-page nonce rides along
1016 + // as a query parameter and gates the user_meta persist in
1017 + // Metasync_Per_Page_Helper::resolve().
1018 + wp_localize_script(
1019 + $this->plugin_name . '-per-page',
1020 + 'metasyncPerPage',
1021 + array('nonces' => Metasync_Per_Page_Helper::page_nonces())
1022 + );
994 1023 }
995 1024
996 1025 // Dashboard iframe height (only on dashboard page)
997 1026 if ($current_page === self::$page_slug . '-dashboard' || $current_page === self::$page_slug) {
@@ -1042,12 +1071,12 @@
1042 1071 true
1043 1072 );
1044 1073 // Pass the active tab from server (handles POST redirect)
1045 1074 $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']));
1075 + if (isset($_GET['tab']) && in_array($_GET['tab'], ['general', 'news', 'video'], true)) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed tab selection for the UI
1076 + $sitemap_active_tab = sanitize_text_field(wp_unslash($_GET['tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed tab selection for the UI
1077 + } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only, allow-listed tab selection for the UI
1078 + $sitemap_active_tab = sanitize_text_field(wp_unslash($_POST['redirect_tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only, allow-listed tab selection for the UI
1050 1079 }
1051 1080 wp_localize_script($this->plugin_name . '-sitemap-tabs', 'metasyncSitemapTabs', [
1052 1081 'activeTab' => $sitemap_active_tab,
1053 1082 ]);
@@ -1375,8 +1404,10 @@
1375 1404 'nonce' => wp_create_nonce('metasync_nonce'),
1376 1405 'sa_connect_nonce' => $sa_connect_nonce,
1377 1406 'reset_auth_nonce' => wp_create_nonce('metasync_reset_auth_nonce'),
1378 1407 'burst_ping_nonce' => wp_create_nonce('metasync_burst_ping'),
1408 + 'clear_otto_cache_nonce' => wp_create_nonce('metasync_clear_otto_cache'),
1409 + 'lglogin_nonce' => wp_create_nonce('metasync_lglogin'),
1379 1410 'heartbeat_state' => $heartbeat_state,
1380 1411 'dashboard_domain' => self::get_effective_dashboard_domain(),
1381 1412 'support_email' => Metasync::SUPPORT_EMAIL,
1382 1413 'documentation_domain' => Metasync::DOCUMENTATION_DOMAIN,
@@ -1440,9 +1471,9 @@
1440 1471 add_action('admin_notices', array($this, 'display_page_builder_notice'));
1441 1472 // Display update warning banner if plugin update is available
1442 1473 add_action('admin_notices', array($this, 'display_update_warning_banner'));
1443 1474 // Enqueue wizard assets if on wizard page
1444 - if (isset($_GET['page']) && strpos($_GET['page'], '-setup-wizard') !== false) {
1475 + if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-setup-wizard') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick scripts
1445 1476 wp_enqueue_script(
1446 1477 $this->plugin_name . '-setup-wizard',
1447 1478 plugin_dir_url(__FILE__) . 'js/metasync-setup-wizard.js',
1448 1479 array('jquery'),
@@ -1466,14 +1497,44 @@
1466 1497 }
1467 1498
1468 1499 /**
1469 1500 * Settings of HeartBeat API for admin area.
1470 - * Set time interval of send request.
1501 + *
1502 + * The only thing the plugin rides the beat for is a periodic admin
1503 + * telemetry ping (the `heartbeat-send` handler in admin/js/metasync-admin.js),
1504 + * so it slows the beat down rather than speeding it up. Two contexts are
1505 + * left on whatever core decided, because core leans on the beat there:
1506 + *
1507 + * - The front end, where the beat drives wp-auth-check — the "your session
1508 + * has expired, log in again" prompt.
1509 + * - The editor screens, where each tick renews the post lock. A lock only
1510 + * lives for wp_check_post_lock_window() seconds (150 by default), so any
1511 + * interval longer than that drops the lock between beats and lets a
1512 + * second editor silently take over a post someone still has open.
1513 + *
1514 + * Core clamps this to 1-3600 seconds in wp-includes/js/heartbeat.js, so an
1515 + * over-long interval is not corrected on our behalf.
1516 + *
1517 + * @param array $settings Heartbeat settings array.
1518 + * @return array Heartbeat settings array.
1471 1519 */
1472 1520 function metasync_heartbeat_settings($settings)
1473 1521 {
1474 - global $heartbeat_frequency;
1475 - $settings['interval'] = 300;
1522 + if (!is_admin()) {
1523 + return $settings;
1524 + }
1525 +
1526 + # $pagenow is set in wp-includes/vars.php, long before this filter runs;
1527 + # core's own wp_heartbeat_set_suspension() reads it exactly this way.
1528 + global $pagenow;
1529 + $lock_dependent_screens = array('post.php', 'post-new.php', 'customize.php');
1530 + if (isset($pagenow) && in_array($pagenow, $lock_dependent_screens, true)) {
1531 + return $settings;
1532 + }
1533 +
1534 + # 60s matches heartbeat.js's own default and stays well inside the
1535 + # 150s post-lock window, so nothing core does can fall through a gap.
1536 + $settings['interval'] = 60;
1476 1537 return $settings;
1477 1538 }
1478 1539
1479 1540 /**
@@ -1611,13 +1672,8 @@
1611 1672 {
1612 1673 Metasync_Connect_Manager::instance()->test_searchatlas_ajax_endpoint();
1613 1674 }
1614 1675
1615 - public function simple_ajax_test()
1616 - {
1617 - Metasync_Connect_Manager::instance()->simple_ajax_test();
1618 - }
1619 -
1620 1676 /**
1621 1677 * Create Admin Dashboard Iframe Page
1622 1678 * Embeds the Search Atlas dashboard directly in WordPress admin
1623 1679 */
@@ -1646,9 +1702,9 @@
1646 1702 <!-- OTTO Cache TTL Setting -->
1647 1703 <div style="margin-bottom: 30px; padding-top: 20px;">
1648 1704 <h4 style="margin-top: 0; color: var(--dashboard-text-primary);"><?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> Cache TTL</h4>
1649 1705 <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.
1706 + Configure how long <?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> API suggestions are cached before a fresh API call. The stale fallback expires after 12 hours.
1651 1707 </p>
1652 1708
1653 1709 <div style="background: rgba(255,255,255,0.02); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
1654 1710 <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
@@ -1704,9 +1760,9 @@
1704 1760 } else {
1705 1761 echo '<p style="color: var(--dashboard-text-secondary);">ℹ️ No cache plugins detected.</p>';
1706 1762 }
1707 1763 } catch (Exception $e) {
1708 - error_log('MetaSync Cache Status Error: ' . $e->getMessage());
1764 + error_log('MetaSync Cache Status Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1709 1765 echo '<p style="color: var(--dashboard-error);">⚠️ An error occurred while retrieving cache plugin status.</p>';
1710 1766 }
1711 1767 } else {
1712 1768 echo '<p style="color: var(--dashboard-error);">⚠️ Cache Purge class not loaded.</p>';
@@ -1723,12 +1779,12 @@
1723 1779 </form>
1724 1780
1725 1781 <?php
1726 1782 // 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'])) : '';
1783 + if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1784 + $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1785 + $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1786 + $plugins = isset($_GET['plugins']) ? sanitize_text_field(wp_unslash($_GET['plugins'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1731 1787
1732 1788 if ($cleared > 0) {
1733 1789 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1734 1790 echo '✅ <strong>Success!</strong> Cleared cache for ' . intval( $cleared ) . ' plugin(s)';
@@ -1748,10 +1804,10 @@
1748 1804 echo '</p></div>';
1749 1805 }
1750 1806 }
1751 1807
1752 - if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') {
1753 - $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : '';
1808 + if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1809 + $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1754 1810 if (empty($message)) {
1755 1811 $message = 'An unknown error occurred while clearing cache. Please check error logs for details.';
1756 1812 }
1757 1813 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
@@ -1848,12 +1904,13 @@
1848 1904 </form>
1849 1905
1850 1906 <?php
1851 1907 // 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'])) : '';
1908 + if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1909 + // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each value is unslashed and sanitized on the following assignments.
1910 + $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_cleared']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1911 + $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_failed']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1912 + $hc_not_detected = isset($_GET['hc_not_detected']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_not_detected']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1856 1913
1857 1914 if ($hc_cleared) {
1858 1915 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1859 1916 echo '✅ <strong>Success!</strong> Purged hosting cache on: ' . esc_html($hc_cleared);
@@ -1868,8 +1925,9 @@
1868 1925 echo '<div class="notice notice-info inline" style="margin-top: 10px;"><p>';
1869 1926 echo 'ℹ️ No enabled hosting providers were detected on this server (' . esc_html($hc_not_detected) . ').';
1870 1927 echo '</p></div>';
1871 1928 }
1929 + // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1872 1930 }
1873 1931 ?>
1874 1932
1875 1933 <script>
@@ -2034,11 +2092,11 @@
2034 2092 </div>
2035 2093
2036 2094 <?php
2037 2095 // 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']))) : '';
2096 + if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2097 + $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2098 + $url = isset($_GET['url']) ? urldecode(sanitize_text_field(wp_unslash($_GET['url']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2041 2099
2042 2100 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
2043 2101 if (!empty($url)) {
2044 2102 echo '✅ <strong>Success!</strong> Cleared cache for URL: <code>' . esc_html($url) . '</code> (' . intval( $cleared_count ) . ' entries)';
@@ -2047,10 +2105,10 @@
2047 2105 }
2048 2106 echo '</p></div>';
2049 2107 }
2050 2108
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.';
2109 + if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2110 + $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : 'An unknown error occurred.'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2053 2111 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
2054 2112 echo '❌ <strong>Error:</strong> ' . esc_html($message);
2055 2113 echo '</p></div>';
2056 2114 }
@@ -2089,9 +2147,9 @@
2089 2147 <td>
2090 2148 <input type="url"
2091 2149 id="otto_cache_url"
2092 2150 name="otto_cache_url"
2093 - value="<?php echo isset($_GET['url']) ? esc_attr(urldecode(sanitize_text_field(wp_unslash($_GET['url'])))) : ''; ?>"
2151 + value="<?php echo isset($_GET['url']) ? esc_attr(urldecode(sanitize_text_field(wp_unslash($_GET['url'])))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce ?>"
2094 2152 class="regular-text"
2095 2153 placeholder="https://example.com/page/"
2096 2154 required />
2097 2155 <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 +2220,10 @@
2162 2220 /**
2163 2221 * AJAX handler for saving hosting cache settings
2164 2222 */
2165 2223 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')) {
2224 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2225 + if (!isset($_POST['hosting_cache_nonce']) || !wp_verify_nonce(wp_unslash($_POST['hosting_cache_nonce']), 'metasync_hosting_cache_nonce')) {
2167 2226 wp_send_json_error(array('message' => 'Security check failed'));
2168 2227 return;
2169 2228 }
2170 2229
@@ -2185,9 +2244,10 @@
2185 2244 /**
2186 2245 * AJAX handler: save object cache behaviour settings
2187 2246 */
2188 2247 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')) {
2248 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2249 + if (!isset($_POST['object_cache_nonce']) || !wp_verify_nonce(wp_unslash($_POST['object_cache_nonce']), 'metasync_object_cache_nonce')) {
2190 2250 wp_send_json_error(array('message' => 'Security check failed'));
2191 2251 return;
2192 2252 }
2193 2253
@@ -2214,9 +2274,10 @@
2214 2274 /**
2215 2275 * AJAX handler for saving OTTO Cache TTL
2216 2276 */
2217 2277 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')) {
2278 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2279 + if (!isset($_POST['otto_cache_ttl_nonce']) || !wp_verify_nonce(wp_unslash($_POST['otto_cache_ttl_nonce']), 'metasync_otto_cache_ttl_nonce')) {
2219 2280 wp_send_json_error(array('message' => 'Security check failed.'));
2220 2281 return;
2221 2282 }
2222 2283
@@ -2242,9 +2303,10 @@
2242 2303 /**
2243 2304 * admin_post handler: purge WP Engine and Kinsta hosting-level caches
2244 2305 */
2245 2306 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')) {
2307 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2308 + if (!isset($_POST['hosting_cache_purge_nonce']) || !wp_verify_nonce(wp_unslash($_POST['hosting_cache_purge_nonce']), 'metasync_hosting_cache_purge_nonce')) {
2247 2309 wp_die('Security check failed');
2248 2310 }
2249 2311
2250 2312 if (!Metasync::current_user_has_plugin_access()) {
@@ -2264,9 +2326,9 @@
2264 2326 WpeCommon::purge_varnish_cache();
2265 2327 WpeCommon::purge_memcached();
2266 2328 $cleared[] = 'WP Engine';
2267 2329 } catch (Exception $e) {
2268 - error_log('MetaSync: WP Engine hosting cache purge failed - ' . $e->getMessage());
2330 + error_log('MetaSync: WP Engine hosting cache purge failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2269 2331 $failed[] = 'WP Engine';
2270 2332 }
2271 2333 } else {
2272 2334 $not_detected[] = 'WP Engine';
@@ -2279,9 +2341,9 @@
2279 2341 try {
2280 2342 KinstaCache::get_instance()->kinsta_cache_purge_full();
2281 2343 $cleared[] = 'Kinsta';
2282 2344 } catch (Exception $e) {
2283 - error_log('MetaSync: Kinsta hosting cache purge failed - ' . $e->getMessage());
2345 + error_log('MetaSync: Kinsta hosting cache purge failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2284 2346 $failed[] = 'Kinsta';
2285 2347 }
2286 2348 } else {
2287 2349 $not_detected[] = 'Kinsta';
@@ -2676,9 +2738,9 @@
2676 2738 # Initialize MetaSync API request class and trigger synchronization
2677 2739 (new Metasync_Sync_Requests())->SyncCustomerParams();
2678 2740 } catch (Exception $e) {
2679 2741 # Log any API request errors for debugging
2680 - error_log('Metasync API Error: ' . $e->getMessage());
2742 + error_log('Metasync API Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2681 2743 }
2682 2744 }
2683 2745
2684 2746 /*
@@ -2693,9 +2755,9 @@
2693 2755 # Initialize MetaSync API request class and trigger synchronization
2694 2756 (new Metasync_Sync_Requests())->SyncCustomerParams();
2695 2757 } catch (Exception $e) {
2696 2758 # Log any API request errors for debugging
2697 - error_log('Metasync API Error: ' . $e->getMessage());
2759 + error_log('Metasync API Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2698 2760 }
2699 2761 }
2700 2762
2701 2763 /**
@@ -2728,8 +2790,15 @@
2728 2790 // Handle form submissions
2729 2791 if (isset($_POST['metasync_media_optimization_nonce'])) {
2730 2792 check_admin_referer('metasync_save_media_optimization', 'metasync_media_optimization_nonce');
2731 2793
2794 + // Settings shape the whole site's conversion pipeline — restrict
2795 + // the save (and the reset) to site admins, not just anyone who can
2796 + // open the page via plugin_access_roles.
2797 + if (!current_user_can('manage_options')) {
2798 + wp_die(esc_html__('Sorry, you are not allowed to manage media optimization settings.', 'metasync'));
2799 + }
2800 +
2732 2801 // Handle reset to defaults
2733 2802 if (!empty($_POST['metasync_media_reset'])) {
2734 2803 $defaults = Metasync_Media_Settings::get_defaults();
2735 2804 Metasync_Media_Settings::save_settings($defaults);
@@ -2734,8 +2803,9 @@
2734 2803 $defaults = Metasync_Media_Settings::get_defaults();
2735 2804 Metasync_Media_Settings::save_settings($defaults);
2736 2805 $save_success = true;
2737 2806 } elseif (isset($_POST['metasync_media'])) {
2807 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Media_Settings::save_settings().
2738 2808 $input = wp_unslash($_POST['metasync_media']);
2739 2809 Metasync_Media_Settings::save_settings($input);
2740 2810 $save_success = true;
2741 2811 }
@@ -2767,8 +2837,15 @@
2767 2837 * Code Minification page callback
2768 2838 */
2769 2839 public function create_admin_code_minification_page()
2770 2840 {
2841 + // Settings shape the whole site's asset pipeline — restrict
2842 + // the save (and the reset) to site admins, not just anyone who can
2843 + // open the page via plugin_access_roles.
2844 + if (!empty($_POST) && !current_user_can('manage_options')) {
2845 + wp_die(esc_html__('Sorry, you are not allowed to manage code minification settings.', 'metasync'));
2846 + }
2847 +
2771 2848 $this->render_layout_open('Code Minification', 'code_minification', 'Minify CSS, JavaScript, and HTML to improve performance.');
2772 2849 // Load settings and compatibility classes
2773 2850 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-settings.php';
2774 2851 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-cache.php';
@@ -2785,8 +2862,9 @@
2785 2862 $defaults = Metasync_Minification_Settings::get_defaults();
2786 2863 Metasync_Minification_Settings::save_settings($defaults);
2787 2864 $save_success = true;
2788 2865 } elseif (isset($_POST['metasync_code_min'])) {
2866 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Minification_Settings::save_settings().
2789 2867 $input = (array) wp_unslash($_POST['metasync_code_min']);
2790 2868 Metasync_Minification_Settings::save_settings($input);
2791 2869 $save_success = true;
2792 2870 }
@@ -2819,8 +2897,15 @@
2819 2897 if (!$attachment_id) {
2820 2898 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2821 2899 }
2822 2900
2901 + // Ownership: under the replace strategy this permanently deletes the
2902 + // attachment's original file, so upload_files alone is not enough —
2903 + // the caller must be allowed to edit this specific attachment.
2904 + if (!current_user_can('edit_post', $attachment_id)) {
2905 + wp_send_json_error(__('Permission denied.', 'metasync'));
2906 + }
2907 +
2823 2908 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-settings.php';
2824 2909 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-image-converter.php';
2825 2910 $settings = Metasync_Media_Settings::get_settings();
2826 2911
@@ -2834,8 +2919,9 @@
2834 2919 $max_bytes = 10 * 1024 * 1024;
2835 2920 if (filesize($file) > $max_bytes) {
2836 2921 $size_mb = round(filesize($file) / 1024 / 1024, 1);
2837 2922 wp_send_json_error(sprintf(
2923 + /* translators: %s: file size in megabytes. */
2838 2924 __('Optimization skipped: file size (%s MB) exceeds the 10 MB safety limit to prevent memory issues.', 'metasync'),
2839 2925 $size_mb
2840 2926 ));
2841 2927 }
@@ -2872,8 +2958,14 @@
2872 2958 if (!$attachment_id) {
2873 2959 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2874 2960 }
2875 2961
2962 + // Ownership: reverting deletes the attachment's converted files, so
2963 + // restrict it to users who may edit this specific attachment.
2964 + if (!current_user_can('edit_post', $attachment_id)) {
2965 + wp_send_json_error(__('Permission denied.', 'metasync'));
2966 + }
2967 +
2876 2968 // Replace-strategy conversions have no original to restore — reverting
2877 2969 // would delete the attachment's only file. Refuse up front, like bulk revert does.
2878 2970 if (!Metasync_Image_Converter::can_revert($attachment_id)) {
2879 2971 wp_send_json_error(__('This image cannot be reverted. The original file no longer exists (replace strategy).', 'metasync'));
@@ -2931,8 +3023,15 @@
2931 3023 public function ajax_batch_progress()
2932 3024 {
2933 3025 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
2934 3026
3027 + // Read-only progress/stats polling — the same capability that can
3028 + // open the media optimization page is enough, but a nonce alone
3029 + // never was.
3030 + if (!current_user_can('upload_files')) {
3031 + wp_send_json_error(__('Permission denied.', 'metasync'));
3032 + }
3033 +
2935 3034 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-batch-optimizer.php';
2936 3035 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
2937 3036
2938 3037 $progress = Metasync_Media_Batch_Optimizer::get_progress();
@@ -2963,10 +3062,19 @@
2963 3062 $settings = Metasync_Media_Settings::get_settings();
2964 3063
2965 3064 $success = 0;
2966 3065 $failed = 0;
3066 + $denied = 0;
2967 3067
2968 3068 foreach ($ids as $id) {
3069 + // Per-attachment ownership: replace mode deletes originals, so
3070 + // upload_files alone must not allow converting images the caller
3071 + // does not own/cannot edit.
3072 + if (!current_user_can('edit_post', $id)) {
3073 + $denied++;
3074 + continue;
3075 + }
3076 +
2969 3077 if (Metasync_Image_Converter::convert_attachment($id, $settings)) {
2970 3078 $success++;
2971 3079 } else {
2972 3080 $failed++;
@@ -2975,8 +3083,9 @@
2975 3083
2976 3084 wp_send_json_success([
2977 3085 'success' => $success,
2978 3086 'failed' => $failed,
3087 + 'denied' => $denied,
2979 3088 ]);
2980 3089 }
2981 3090
2982 3091 /**
@@ -2999,11 +3108,19 @@
2999 3108
3000 3109 $success = 0;
3001 3110 $failed = 0;
3002 3111 $skipped = 0;
3112 + $denied = 0;
3003 3113 $errors = [];
3004 3114
3005 3115 foreach ($ids as $id) {
3116 + // Per-attachment ownership: reverting deletes the attachment's
3117 + // converted files, so restrict it per image, not per role.
3118 + if (!current_user_can('edit_post', $id)) {
3119 + $denied++;
3120 + continue;
3121 + }
3122 +
3006 3123 $format = get_post_meta($id, '_metasync_converted_format', true);
3007 3124
3008 3125 if (!$format) {
3009 3126 $skipped++;
@@ -3013,8 +3130,9 @@
3013 3130 if (!Metasync_Image_Converter::can_revert($id)) {
3014 3131 $skipped++;
3015 3132 $file = get_attached_file($id);
3016 3133 $name = $file ? basename($file) : "ID {$id}";
3134 + /* translators: %s: attachment file name. */
3017 3135 $errors[] = sprintf(__('%s: skipped — original image unavailable.', 'metasync'), $name);
3018 3136 continue;
3019 3137 }
3020 3138
@@ -3023,8 +3141,9 @@
3023 3141 } else {
3024 3142 $failed++;
3025 3143 $file = get_attached_file($id);
3026 3144 $name = $file ? basename($file) : "ID {$id}";
3145 + /* translators: %s: attachment file name. */
3027 3146 $errors[] = sprintf(__('%s: revert failed (original file may not exist).', 'metasync'), $name);
3028 3147 }
3029 3148 }
3030 3149
@@ -3031,8 +3150,9 @@
3031 3150 wp_send_json_success([
3032 3151 'success' => $success,
3033 3152 'failed' => $failed,
3034 3153 'skipped' => $skipped,
3154 + 'denied' => $denied,
3035 3155 'errors' => $errors,
3036 3156 ]);
3037 3157 }
3038 3158
@@ -3108,8 +3228,86 @@
3108 3228 Metasync_Media_Batch_Optimizer::process_batch_tick();
3109 3229 }
3110 3230
3111 3231 /**
3232 + * AJAX: Start SEO Restore batch.
3233 + */
3234 + public function ajax_seo_restore_start()
3235 + {
3236 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3237 +
3238 + if (!current_user_can('manage_options')) {
3239 + wp_send_json_error(__('Permission denied.', 'metasync'));
3240 + }
3241 +
3242 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3243 +
3244 + $progress = Metasync_Seo_Restore::start_batch();
3245 + wp_send_json_success($progress);
3246 + }
3247 +
3248 + /**
3249 + * AJAX: Cancel SEO Restore batch.
3250 + */
3251 + public function ajax_seo_restore_cancel()
3252 + {
3253 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3254 +
3255 + if (!current_user_can('manage_options')) {
3256 + wp_send_json_error(__('Permission denied.', 'metasync'));
3257 + }
3258 +
3259 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3260 +
3261 + Metasync_Seo_Restore::cancel_batch();
3262 + wp_send_json_success();
3263 + }
3264 +
3265 + /**
3266 + * AJAX: Get SEO Restore batch progress.
3267 + */
3268 + public function ajax_seo_restore_progress()
3269 + {
3270 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3271 +
3272 + if (!current_user_can('manage_options')) {
3273 + wp_send_json_error(__('Permission denied.', 'metasync'));
3274 + }
3275 +
3276 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3277 +
3278 + $progress = Metasync_Seo_Restore::get_progress();
3279 + wp_send_json_success($progress);
3280 + }
3281 +
3282 + /**
3283 + * AJAX: Process one SEO Restore tick (browser-driven chaining).
3284 + */
3285 + public function ajax_seo_restore_process_tick()
3286 + {
3287 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3288 +
3289 + if (!current_user_can('manage_options')) {
3290 + wp_send_json_error(__('Permission denied.', 'metasync'));
3291 + }
3292 +
3293 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3294 +
3295 + $progress = Metasync_Seo_Restore::process_ajax_tick();
3296 + wp_send_json_success($progress);
3297 + }
3298 +
3299 + /**
3300 + * Cron handler: Process SEO Restore batch tick.
3301 + */
3302 + public function handle_seo_restore_cron()
3303 + {
3304 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3305 +
3306 + Metasync_Seo_Restore::process_batch_tick();
3307 + }
3308 +
3309 + /**
3112 3310 * Report Issue page callback
3113 3311 */
3114 3312 public function create_admin_report_issue_page()
3115 3313 {
@@ -3121,8 +3319,17 @@
3121 3319 * XML Sitemap page callback
3122 3320 */
3123 3321 public function create_admin_xml_sitemap_page()
3124 3322 {
3323 + // Every form action below mutates site-wide state — sitemap options,
3324 + // sitemap files on disk, robots.txt, other plugins' sitemap generators
3325 + // — so they are restricted to site admins, not just anyone who can
3326 + // open the page via plugin_access_roles. The nonce is CSRF
3327 + // protection, not authorization.
3328 + if (!empty($_POST) && !current_user_can('manage_options')) {
3329 + wp_die(esc_html__('Sorry, you are not allowed to manage sitemap settings.', 'metasync'));
3330 + }
3331 +
3125 3332 // Load sitemap generator class
3126 3333 require_once plugin_dir_path(dirname(__FILE__)) . 'sitemap/class-metasync-sitemap-generator.php';
3127 3334
3128 3335 $sitemap_generator = new Metasync_Sitemap_Generator();
@@ -3153,10 +3360,13 @@
3153 3360 // Regenerate sitemap with new content settings
3154 3361 $result = $sitemap_generator->generate_sitemap();
3155 3362 if (is_wp_error($result)) {
3156 3363 echo '<div class="notice notice-error"><p>' . esc_html(
3364 + /* translators: %s: error message. */
3157 3365 sprintf(__('Settings saved but sitemap generation failed: %s', 'metasync'), $result->get_error_message())
3158 3366 ) . '</p></div>';
3367 + } elseif (false === $result) {
3368 + echo '<div class="notice notice-error"><p>' . esc_html__('Settings saved but sitemap generation failed: the sitemap data could not be stored, so no sitemap is being served. Check your object cache and database write settings, then try again.', 'metasync') . '</p></div>';
3159 3369 } else {
3160 3370 echo '<div class="notice notice-success"><p>' . esc_html__('Sitemap content settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3161 3371 }
3162 3372 }
@@ -3167,10 +3377,10 @@
3167 3377
3168 3378 // Build generic taxonomy filters
3169 3379 $news_taxonomies = [];
3170 3380 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);
3381 + foreach (wp_unslash($_POST['news_taxonomies']) as $tax_name => $term_ids) {
3382 + $news_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) wp_unslash($term_ids));
3173 3383 }
3174 3384 }
3175 3385
3176 3386 $news_settings = [
@@ -3195,9 +3405,9 @@
3195 3405 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3196 3406 } else {
3197 3407 // Also remove physical file if it exists
3198 3408 if (file_exists(ABSPATH . 'news-sitemap.xml')) {
3199 - @unlink(ABSPATH . 'news-sitemap.xml');
3409 + @wp_delete_file(ABSPATH . 'news-sitemap.xml');
3200 3410 }
3201 3411 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
3202 3412 }
3203 3413 }
@@ -3208,10 +3418,10 @@
3208 3418
3209 3419 // Build generic taxonomy filters
3210 3420 $video_taxonomies = [];
3211 3421 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);
3422 + foreach (wp_unslash($_POST['video_taxonomies']) as $tax_name => $term_ids) {
3423 + $video_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) wp_unslash($term_ids));
3214 3424 }
3215 3425 }
3216 3426
3217 3427 $video_settings = [
@@ -3219,8 +3429,12 @@
3219 3429 'post_types' => array_map('sanitize_key', (array) ($_POST['video_post_types'] ?? ['post', 'page'])),
3220 3430 'auto_detect' => isset($_POST['auto_detect']),
3221 3431 'taxonomies' => $video_taxonomies,
3222 3432 'excluded_urls' => sanitize_textarea_field(wp_unslash($_POST['video_excluded_urls'] ?? '')),
3433 + // This array replaces the stored option wholesale, so every
3434 + // setting must be represented here or it is wiped on save.
3435 + 'video_url_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_url_meta_keys'] ?? '')),
3436 + 'video_thumbnail_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_thumbnail_meta_keys'] ?? '')),
3223 3437 ];
3224 3438
3225 3439 // Always invalidate old cache before saving new settings
3226 3440 delete_transient('metasync_vsm_' . md5('video-sitemap.xml'));
@@ -3233,9 +3447,9 @@
3233 3447 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3234 3448 } else {
3235 3449 // Also remove physical file if it exists
3236 3450 if (file_exists(ABSPATH . 'video-sitemap.xml')) {
3237 - @unlink(ABSPATH . 'video-sitemap.xml');
3451 + @wp_delete_file(ABSPATH . 'video-sitemap.xml');
3238 3452 }
3239 3453 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
3240 3454 }
3241 3455 }
@@ -3303,16 +3517,20 @@
3303 3517 $result = $sitemap_generator->generate_sitemap();
3304 3518
3305 3519 if (is_wp_error($result)) {
3306 3520 $error_msg = $result->get_error_message();
3307 - error_log('[MetaSync] Sitemap generation failed: ' . $error_msg);
3521 + error_log('[MetaSync] Sitemap generation failed: ' . $error_msg); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3308 3522 echo '<div class="notice notice-error"><p>' . esc_html(
3523 + /* translators: %s: error message. */
3309 3524 sprintf(__('Sitemap generation failed: %s', 'metasync'), $error_msg)
3310 3525 ) . '</p></div>';
3526 + } elseif (false === $result) {
3527 + echo '<div class="notice notice-error"><p>' . esc_html__('Sitemap generation failed: the sitemap data could not be stored, so no sitemap is being served. Check your object cache and database write settings, then try again.', 'metasync') . '</p></div>';
3311 3528 } else {
3312 3529 $message = esc_html__('Sitemap generated successfully!', 'metasync');
3313 3530 if (!empty($extras)) {
3314 3531 $message .= ' ' . sprintf(
3532 + /* translators: %s: list of sitemap types. */
3315 3533 esc_html__('Also generated %s sitemap(s).', 'metasync'),
3316 3534 implode(' & ', $extras)
3317 3535 );
3318 3536 }
@@ -3501,47 +3719,8 @@
3501 3719 Metasync_Admin_Pages::get_instance($this)->create_admin_breadcrumbs_page();
3502 3720 }
3503 3721
3504 3722 /**
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 3723 * Google Console page callback
3545 3724 */
3546 3725 public function create_admin_google_console_page()
3547 3726 {
@@ -3647,8 +3826,9 @@
3647 3826 if (!get_transient('metasync_llms_conflict')) {
3648 3827 return;
3649 3828 }
3650 3829 echo '<div class="notice notice-info is-dismissible"><p>';
3830 + /* translators: %s: effective plugin name (whitelabel-aware). */
3651 3831 echo esc_html(sprintf(__('Note: Another SEO plugin (Yoast, Rank Math, or AIOSEO) may also be generating /llms.txt. %s\'s version takes priority when enabled.', 'metasync'), Metasync::get_effective_plugin_name()));
3652 3832 echo '</p></div>';
3653 3833 }
3654 3834
@@ -3840,9 +4020,9 @@
3840 4020 return;
3841 4021 }
3842 4022
3843 4023 // Get pagination parameters
3844 - $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
4024 + $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
3845 4025 $per_page = Metasync_Per_Page_Helper::resolve('sync_log', 10);
3846 4026 $offset = ($page - 1) * $per_page;
3847 4027
3848 4028 // Get filters
@@ -3847,10 +4027,10 @@
3847 4027
3848 4028 // Get filters
3849 4029 $filters = [
3850 4030 // 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'])) : '',
4031 + 'date_range' => isset($_GET['date_range']) ? sanitize_text_field(wp_unslash($_GET['date_range'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
4032 + 'status' => isset($_GET['status']) ? sanitize_text_field(wp_unslash($_GET['status'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
3853 4033 ];
3854 4034
3855 4035 // Map date_range to concrete date_from/date_to for DB queries
3856 4036 $date_range = $filters['date_range'];
@@ -3859,28 +4039,28 @@
3859 4039 $date_to = '';
3860 4040
3861 4041 if (!empty($date_range)) {
3862 4042 // End boundary is now by default
3863 - $date_to = date('Y-m-d H:i:s', $wp_now_ts);
4043 + $date_to = gmdate('Y-m-d H:i:s', $wp_now_ts);
3864 4044
3865 4045 if ($date_range === 'today') {
3866 4046 $start_ts = strtotime('today', $wp_now_ts);
3867 - $date_from = date('Y-m-d H:i:s', $start_ts);
4047 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3868 4048 } elseif ($date_range === 'yesterday') {
3869 4049 $start_ts = strtotime('yesterday', $wp_now_ts);
3870 4050 $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);
4051 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
4052 + $date_to = gmdate('Y-m-d H:i:s', $end_ts);
3873 4053 } elseif ($date_range === 'this_week') {
3874 4054 $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
4055 + $day_of_week = (int) gmdate('w', $wp_now_ts); // 0=Sun..6=Sat
3876 4056 // Convert start_of_week to PHP's 0..6 where 0=Sunday
3877 4057 $delta_days = ($day_of_week - $start_of_week + 7) % 7;
3878 4058 $start_ts = strtotime('-' . $delta_days . ' days', strtotime('today', $wp_now_ts));
3879 - $date_from = date('Y-m-d H:i:s', $start_ts);
4059 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3880 4060 } 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);
4061 + $start_ts = strtotime(gmdate('Y-m-01 00:00:00', $wp_now_ts));
4062 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3883 4063 } elseif ($date_range === 'all') {
3884 4064 // no bounds
3885 4065 }
3886 4066 }
@@ -3907,12 +4087,24 @@
3907 4087 ?>
3908 4088 <div class="dashboard-card">
3909 4089 <div class="sync-log-header">
3910 4090 <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>
4091 + <?php
4092 + // The page title and description are already rendered by
4093 + // render_layout_open() above. Repeating them here printed
4094 + // "Changes Log" twice on screen, so the card keeps only the
4095 + // retention hint — the one piece of copy the layout header
4096 + // does not carry.
4097 + ?>
4098 + <p style="color: var(--dashboard-text-secondary); margin-top: 0; margin-bottom: 0;">
4099 + <?php
4100 + // No opacity here: this used to be a de-emphasised
4101 + // aside trailing a full-contrast sentence, but it is
4102 + // now the card's only copy. At 12px/.75 it composites
4103 + // to 4.28:1 on the card background, under the 4.5:1
4104 + // AA floor; at full strength it reads 6.52:1.
4105 + ?>
4106 + <span style="font-size:13px;">Records are automatically removed after 90 days.</span>
3915 4107 </p>
3916 4108 </div>
3917 4109
3918 4110 <!-- Filters + Clear Log button - Right aligned -->
@@ -3922,9 +4114,9 @@
3922 4114 data-nonce="<?php echo esc_attr(wp_create_nonce('metasync_clear_sync_log')); ?>">
3923 4115 🗑 Clear Log
3924 4116 </button>
3925 4117 <form method="get" class="sync-filters-form" onchange="this.submit()" style="display:flex;flex-direction:row;align-items:center;gap:12px;flex-wrap:nowrap;">
3926 - <input type="hidden" name="page" value="<?php echo esc_attr($_GET['page']); ?>">
4118 + <input type="hidden" name="page" value="<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>">
3927 4119
3928 4120 <select name="date_range" class="sync-filter-select">
3929 4121 <option value="all" <?php selected($filters['date_range'] ?? 'all', 'all'); ?>> All Time</option>
3930 4122 <option value="today" <?php selected($filters['date_range'] ?? '', 'today'); ?>>Today</option>
@@ -3946,9 +4138,9 @@
3946 4138 <div class="sync-log-list">
3947 4139 <?php if (empty($sync_records)): ?>
3948 4140 <div class="sync-log-empty">
3949 4141 <div class="sync-log-empty-icon"><span class="dashicons dashicons-media-default" style="font-size:48px;width:48px;height:48px;color:var(--dashboard-text-secondary);"></span></div>
3950 - <h3>No sync records found</h3>
4142 + <h2>No sync records found</h2>
3951 4143 <p>Sync records will appear here when content/pages receive new updates.</p>
3952 4144 </div>
3953 4145 <?php else: ?>
3954 4146 <?php foreach ($sync_records as $record): ?>
@@ -4005,24 +4197,25 @@
4005 4197 <!-- Pagination -->
4006 4198 <div class="sync-log-pagination">
4007 4199 <div class="sync-log-pagination-info">
4008 4200 Total records: <?php echo intval( $total_records ); ?><?php if ($total_records > 0): ?> | Showing <?php echo intval( $offset ) + 1; ?>-<?php echo intval( min($offset + $per_page, $total_records) ); ?><?php endif; ?>
4009 - <?php echo Metasync_Per_Page_Helper::render_selector('sync_log', $per_page); ?>
4201 + <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper returns a complete escaped selector element.
4202 + echo Metasync_Per_Page_Helper::render_selector('sync_log', absint($per_page)); ?>
4010 4203 </div>
4011 4204
4012 4205 <?php if ($total_pages > 1): ?>
4013 4206 <div class="sync-log-pagination-controls">
4014 4207 <?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>
4208 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $page ) - 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">‹</a>
4016 4209 <?php endif; ?>
4017 4210
4018 4211 <?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) ); ?>"
4212 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $i ); ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>"
4020 4213 class="sync-pagination-btn <?php echo $i === $page ? 'active' : ''; ?>"><?php echo intval( $i ); ?></a>
4021 4214 <?php endfor; ?>
4022 4215
4023 4216 <?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>
4217 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $page ) + 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">›</a>
4025 4218 <?php endif; ?>
4026 4219 </div>
4027 4220 <?php endif; ?>
4028 4221 </div>
@@ -4111,10 +4304,10 @@
4111 4304
4112 4305 // Preserve the user-selected results-per-page value across page
4113 4306 // navigation so a non-default page size survives clicking a page link.
4114 4307 $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];
4308 + if (isset($_GET[$per_page_key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4309 + $per_page = (int) $_GET[$per_page_key]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4117 4310 if (in_array($per_page, Metasync_Per_Page_Helper::allowed_values(), true)) {
4118 4311 $query_parts[] = $per_page_key . '=' . $per_page;
4119 4312 }
4120 4313 }
@@ -4293,18 +4486,8 @@
4293 4486 {
4294 4487 Metasync_Connect_Manager::instance()->handle_session_management_early();
4295 4488 }
4296 4489
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 4490 private function handle_whitelabel_session_logic()
4308 4491 {
4309 4492 Metasync_Connect_Manager::instance()->handle_whitelabel_session_logic();
4310 4493 }
@@ -4400,9 +4583,9 @@
4400 4583 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4401 4584 Effective Threshold
4402 4585 </label>
4403 4586 <div style="padding: 10px 12px; background: var(--dashboard-input-bg); border: 1px solid var(--dashboard-border); border-radius: 6px; color: var(--dashboard-text-secondary);">
4404 - <strong id="effective_threshold_value"><?php echo round($effective_threshold, 2); ?></strong>
4587 + <strong id="effective_threshold_value"><?php echo esc_html((string) round($effective_threshold, 2)); ?></strong>
4405 4588 </div>
4406 4589 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
4407 4590 Calculated as: cores × per-core threshold
4408 4591 </p>
@@ -4420,13 +4603,13 @@
4420 4603 <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo intval($stats['deferrals']); ?></div>
4421 4604 </div>
4422 4605 <div>
4423 4606 <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>
4607 + <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo esc_html((string) round($stats['max_load'], 2)); ?></div>
4425 4608 </div>
4426 4609 <div>
4427 4610 <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>
4611 + <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo esc_html((string) round($stats['avg_load'], 2)); ?></div>
4429 4612 </div>
4430 4613 </div>
4431 4614 </div>
4432 4615
@@ -5131,8 +5314,14 @@
5131 5314 * Show a one-time admin notice when a page builder is detected but the
5132 5315 * "Default Page Builder" setting has never been explicitly saved.
5133 5316 */
5134 5317 public function display_page_builder_notice() {
5318 + // Only relevant on the plugin's own settings page — avoid repeating
5319 + // this notice across every admin screen.
5320 + if (!isset($_GET['page']) || wp_unslash($_GET['page']) !== self::$page_slug) {
5321 + return;
5322 + }
5323 +
5135 5324 $configured = Metasync::get_option('general')['default_page_builder'] ?? '';
5136 5325
5137 5326 // Setting already saved — nothing to warn about
5138 5327 if (!empty($configured)) {
@@ -5145,9 +5334,9 @@
5145 5334 return;
5146 5335 }
5147 5336
5148 5337 // Handle dismiss action
5149 - if (isset($_GET['metasync_dismiss_builder_notice']) && wp_verify_nonce($_GET['_wpnonce'] ?? '', 'metasync_dismiss_builder')) {
5338 + if (isset($_GET['metasync_dismiss_builder_notice'], $_GET['_wpnonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'metasync_dismiss_builder')) {
5150 5339 update_user_meta(get_current_user_id(), 'metasync_builder_notice_dismissed', '1');
5151 5340 return;
5152 5341 }
5153 5342
@@ -5242,9 +5431,10 @@
5242 5431 * Saves the CPU load threshold and returns statistics
5243 5432 */
5244 5433 public function ajax_save_performance_settings() {
5245 5434 # 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')) {
5435 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5436 + if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce(wp_unslash($_POST['meta_sync_nonce']), 'meta_sync_general_setting_nonce')) {
5247 5437 wp_send_json_error(array('message' => 'Invalid nonce'));
5248 5438 return;
5249 5439 }
5250 5440
@@ -5312,9 +5502,9 @@
5312 5502 if (!wp_next_scheduled('metasync_cleanup_transients')) {
5313 5503 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_cleanup_transients');
5314 5504
5315 5505 if (!$scheduled) {
5316 - error_log('MetaSync: Failed to schedule transient cleanup cron job');
5506 + error_log('MetaSync: Failed to schedule transient cleanup cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5317 5507 }
5318 5508 }
5319 5509 }
5320 5510
@@ -5325,9 +5515,9 @@
5325 5515 {
5326 5516 $timestamp = wp_next_scheduled('metasync_cleanup_transients');
5327 5517 if ($timestamp) {
5328 5518 wp_unschedule_event($timestamp, 'metasync_cleanup_transients');
5329 - error_log('MetaSync: Transient cleanup cron job unscheduled');
5519 + error_log('MetaSync: Transient cleanup cron job unscheduled'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- unreachable in production
5330 5520 }
5331 5521 }
5332 5522
5333 5523 /**
@@ -5349,12 +5539,10 @@
5349 5539 {
5350 5540 if (!wp_next_scheduled('metasync_hidden_post_check')) {
5351 5541 $scheduled = wp_schedule_event(time(), 'metasync_weekly', 'metasync_hidden_post_check');
5352 5542
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');
5543 + if (!$scheduled) {
5544 + error_log('MetaSync: Failed to schedule hidden post manager cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5357 5545 }
5358 5546 }
5359 5547 }
5360 5548
@@ -5365,12 +5553,10 @@
5365 5553 public function maybe_schedule_otto_recheck_404_cron()
5366 5554 {
5367 5555 if (!wp_next_scheduled('metasync_otto_recheck_404_exclusions')) {
5368 5556 $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');
5557 + if (!$scheduled) {
5558 + error_log('MetaSync: Failed to schedule OTTO 404 recheck cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5373 5559 }
5374 5560 }
5375 5561 }
5376 5562
@@ -5443,13 +5629,13 @@
5443 5629
5444 5630 try {
5445 5631 // 1. Post revisions
5446 5632 if (!empty($settings['clean_post_revisions'])) {
5447 - $stats['post_revisions'] = (int) $wpdb->query(
5633 + $stats['post_revisions'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5448 5634 "DELETE FROM {$wpdb->posts} WHERE post_type = 'revision'"
5449 5635 );
5450 5636 // Remove postmeta left behind by deleted revisions
5451 - $wpdb->query(
5637 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5452 5638 "DELETE pm FROM {$wpdb->postmeta} pm
5453 5639 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5454 5640 WHERE p.ID IS NULL"
5455 5641 );
@@ -5457,20 +5643,19 @@
5457 5643
5458 5644 // 2. Trashed posts + their postmeta
5459 5645 if (!empty($settings['clean_trashed_posts'])) {
5460 5646 // Collect IDs first to cleanly remove postmeta
5461 - $trashed_ids = $wpdb->get_col(
5647 + $trashed_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5462 5648 "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'trash'"
5463 5649 );
5464 5650 if (!empty($trashed_ids)) {
5465 - $placeholders = implode(',', array_fill(0, count($trashed_ids), '%d'));
5466 - $wpdb->query(
5651 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5467 5652 $wpdb->prepare(
5468 - "DELETE FROM {$wpdb->postmeta} WHERE post_id IN ($placeholders)",
5653 + 'DELETE FROM ' . $wpdb->postmeta . ' WHERE post_id IN (' . implode(',', array_fill(0, count($trashed_ids), '%d')) . ')',
5469 5654 $trashed_ids
5470 5655 )
5471 5656 );
5472 - $stats['trashed_posts'] = (int) $wpdb->query(
5657 + $stats['trashed_posts'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5473 5658 "DELETE FROM {$wpdb->posts} WHERE post_status = 'trash'"
5474 5659 );
5475 5660 } else {
5476 5661 $stats['trashed_posts'] = 0;
@@ -5478,9 +5663,9 @@
5478 5663 }
5479 5664
5480 5665 // 3. Trashed comments
5481 5666 if (!empty($settings['clean_trashed_comments'])) {
5482 - $stats['trashed_comments'] = (int) $wpdb->query(
5667 + $stats['trashed_comments'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5483 5668 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'trash'"
5484 5669 );
5485 5670 }
5486 5671
@@ -5485,9 +5670,9 @@
5485 5670 }
5486 5671
5487 5672 // 4. Spam comments
5488 5673 if (!empty($settings['clean_spam_comments'])) {
5489 - $stats['spam_comments'] = (int) $wpdb->query(
5674 + $stats['spam_comments'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5490 5675 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'spam'"
5491 5676 );
5492 5677 }
5493 5678
@@ -5493,15 +5678,15 @@
5493 5678
5494 5679 // 5. Expired transients — direct SQL, no cache flush
5495 5680 if (!empty($settings['clean_expired_transients'])) {
5496 5681 // Delete timeout rows that have already expired
5497 - $wpdb->query(
5682 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5498 5683 "DELETE FROM {$wpdb->options}
5499 5684 WHERE option_name LIKE '\_transient\_timeout\_%'
5500 5685 AND option_value + 0 < UNIX_TIMESTAMP()"
5501 5686 );
5502 5687 // Delete value rows whose timeout row no longer exists
5503 - $stats['expired_transients'] = (int) $wpdb->query(
5688 + $stats['expired_transients'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5504 5689 "DELETE o FROM {$wpdb->options} o
5505 5690 LEFT JOIN {$wpdb->options} t
5506 5691 ON t.option_name = CONCAT('_transient_timeout_', SUBSTRING(o.option_name, 12))
5507 5692 WHERE o.option_name LIKE '\_transient\_%'
@@ -5511,9 +5696,9 @@
5511 5696 }
5512 5697
5513 5698 // 6. Orphaned postmeta (post_id references a post that no longer exists)
5514 5699 if (!empty($settings['clean_orphaned_postmeta'])) {
5515 - $stats['orphaned_postmeta'] = (int) $wpdb->query(
5700 + $stats['orphaned_postmeta'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5516 5701 "DELETE pm FROM {$wpdb->postmeta} pm
5517 5702 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5518 5703 WHERE p.ID IS NULL"
5519 5704 );
@@ -5525,12 +5710,10 @@
5525 5710 $settings['last_run_at'] = time();
5526 5711 $settings['last_run_stats'] = $stats;
5527 5712 update_option('metasync_db_cleanup_settings', $settings);
5528 5713
5529 - error_log('MetaSync: DB cleanup completed — ' . json_encode($stats));
5530 -
5531 5714 } catch (Exception $e) {
5532 - error_log('MetaSync: DB cleanup failed — ' . $e->getMessage());
5715 + error_log('MetaSync: DB cleanup failed — ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5533 5716 }
5534 5717 }
5535 5718
5536 5719 /**
@@ -5747,10 +5930,11 @@
5747 5930 /**
5748 5931 * AJAX: Save DB cleanup settings and reschedule cron accordingly.
5749 5932 */
5750 5933 public function ajax_save_db_cleanup_settings() {
5934 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5751 5935 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5752 - !wp_verify_nonce($_POST['db_cleanup_settings_nonce'], 'metasync_db_cleanup_settings_nonce')) {
5936 + !wp_verify_nonce(wp_unslash($_POST['db_cleanup_settings_nonce']), 'metasync_db_cleanup_settings_nonce')) {
5753 5937 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
5754 5938 return;
5755 5939 }
5756 5940
@@ -5807,10 +5991,11 @@
5807 5991 * AJAX: Manually trigger the DB cleanup and return stats for the UI.
5808 5992 * Persists current form state first so unsaved checkbox changes are respected.
5809 5993 */
5810 5994 public function ajax_run_db_cleanup() {
5995 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5811 5996 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5812 - !wp_verify_nonce($_POST['db_cleanup_settings_nonce'], 'metasync_db_cleanup_settings_nonce')) {
5997 + !wp_verify_nonce(wp_unslash($_POST['db_cleanup_settings_nonce']), 'metasync_db_cleanup_settings_nonce')) {
5813 5998 wp_send_json_error(array('message' => 'Invalid security token.'));
5814 5999 return;
5815 6000 }
5816 6001
@@ -5886,35 +6071,8 @@
5886 6071 ));
5887 6072 }
5888 6073
5889 6074 /**
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 6075 * AJAX handler to add excluded URL for OTTO
5918 6076 */
5919 6077 public function ajax_otto_add_excluded_url()
5920 6078 {
@@ -6060,8 +6218,9 @@
6060 6218 // If page has raw HTML or custom CSS from conversion, show badge
6061 6219 if ($has_raw_html || !empty($has_custom_css)) {
6062 6220 $label = $this->get_html_source_label();
6063 6221 $tooltip = sprintf(
6222 + /* translators: %s: HTML source label. */
6064 6223 __('This page was created using %s HTML-to-Builder converter', 'metasync'),
6065 6224 $label
6066 6225 );
6067 6226
@@ -6114,8 +6273,9 @@
6114 6273
6115 6274 if ($has_raw_html || !empty($has_custom_css)) {
6116 6275 $label = $this->get_html_source_label();
6117 6276 $message = sprintf(
6277 + /* translators: %s: HTML source label. */
6118 6278 __('This page was created using %s HTML-to-Builder converter. The design is preserved with custom CSS and inline styles.', 'metasync'),
6119 6279 '<strong>' . esc_html($label) . '</strong>'
6120 6280 );
6121 6281
@@ -6129,9 +6289,9 @@
6129 6289 <p class="metasync-editor-message">%s</p>
6130 6290 </div>
6131 6291 </div>',
6132 6292 esc_html($label),
6133 - $message
6293 + wp_kses($message, array('strong' => array()))
6134 6294 );
6135 6295 }
6136 6296 }
6137 6297
@@ -6154,9 +6314,9 @@
6154 6314 ?>
6155 6315 <fieldset class="inline-edit-col-left metasync-quick-edit-source">
6156 6316 <div class="inline-edit-col">
6157 6317 <label>
6158 - <span class="title"><?php _e('Source', 'metasync'); ?></span>
6318 + <span class="title"><?php esc_html_e('Source', 'metasync'); ?></span>
6159 6319 <span class="metasync-quick-edit-badge-container"></span>
6160 6320 </label>
6161 6321 </div>
6162 6322 </fieldset>
@@ -6168,8 +6328,9 @@
6168 6328 */
6169 6329 public function add_html_pages_dashboard_widget()
6170 6330 {
6171 6331 $label = $this->get_html_source_label();
6332 + /* translators: %s: HTML source label. */
6172 6333 $widget_title = sprintf(__('%s Pages', 'metasync'), $label);
6173 6334
6174 6335 wp_add_dashboard_widget(
6175 6336 'metasync_html_pages_widget',
@@ -6322,8 +6483,18 @@
6322 6483 if (!isset($_POST['submit'])) {
6323 6484 return;
6324 6485 }
6325 6486
6487 + // This handler runs on admin_init, which also fires inside
6488 + // admin-ajax.php before its login gate, so a bare isset() check let
6489 + // any request (including logged-out ones) rewrite the auto-submit
6490 + // post types. Require the nonce and plugin access before writing.
6491 + if (!isset($_POST['metasync_instant_indexing_nonce'])
6492 + || !wp_verify_nonce(sanitize_key(wp_unslash($_POST['metasync_instant_indexing_nonce'])), 'metasync_instant_indexing_settings')
6493 + || !Metasync::current_user_has_plugin_access()) {
6494 + return;
6495 + }
6496 +
6326 6497 // Save post types for Google Instant Indexing auto-submit
6327 6498 if (isset($_POST['metasync_post_types'])) {
6328 6499 $post_data = metasync_sanitize_input_array($_POST);
6329 6500 $post_types = is_array($post_data['metasync_post_types']) ? array_map('sanitize_title', $post_data['metasync_post_types']) : [];
@@ -6407,12 +6578,24 @@
6407 6578 if (!empty($seo_controls['enable_googleinstantindex']) && $seo_controls['enable_googleinstantindex'] === 'true') {
6408 6579 $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
6409 6580 $post_types = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
6410 6581
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');
6582 + if (in_array($post->post_type, $post_types)) {
6583 + // save_post is the single owner of auto-submit and also fires
6584 + // on REST-created posts, where nothing else may have loaded
6585 + // the Google Index helpers yet — load them on demand instead
6586 + // of silently skipping.
6587 + if (!function_exists('google_index_direct')) {
6588 + $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
6589 + if (file_exists($google_index_path)) {
6590 + require_once $google_index_path;
6591 + }
6592 + }
6593 + if (function_exists('google_index_direct')) {
6594 + $service_info = google_index_direct()->get_service_account_info();
6595 + if (!isset($service_info['error'])) {
6596 + google_index_direct()->index_post($post_id, $post->post_type, 'update');
6597 + }
6415 6598 }
6416 6599 }
6417 6600 }
6418 6601