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 +680 -363 2.6.12 → 2.7.1 View file →
@@ -33,9 +33,8 @@
33 33 const SECTION_METASYNC = "metasync_settings";
34 34 const SECTION_SEARCHENGINE = "searchengine_settings";
35 35 const SECTION_LOCALSEO = "local_seo";
36 36 const SECTION_CODESNIPPETS = "code_snippets";
37 - const SECTION_OPTIMAL_SETTINGS = "optimal_settings";
38 37 const SECTION_SITE_SETTINGS = "site_settings";
39 38 const SECTION_COMMON_SETTINGS = "common_settings";
40 39 const SECTION_COMMON_META_SETTINGS = "common_meta_settings";
41 40 const SECTION_SOCIAL_META = "social_meta";
@@ -42,9 +41,8 @@
42 41 const SECTION_SEO_CONTROLS = "seo_controls";
43 42 const SECTION_SEO_CONTROLS_ADVANCED = "seo_controls_advanced";
44 43 const SECTION_SEO_CONTROLS_INSTANT_INDEX = "seo_controls_instant_index";
45 44 const SECTION_PLUGIN_VISIBILITY = "plugin_visibility_settings";
46 - const SECTION_BREADCRUMBS = "breadcrumbs_settings";
47 45 const SECTION_LLMS_TXT = "llms_txt_settings";
48 46
49 47 /**
50 48 * The ID of this plugin.
@@ -200,9 +198,9 @@
200 198 // Set menu title using the effective title (includes whitelabel company name if available)
201 199 $this->menu_title = $this->get_effective_menu_title();
202 200 $raw_slug = isset($data['white_label_plugin_menu_slug']) ? $data['white_label_plugin_menu_slug'] : '';
203 201 $clean_slug = sanitize_title($raw_slug);
204 - # Self-heal a legacy URL-shaped slug (WP-413): persist the sanitized value so every
202 + # Self-heal a legacy URL-shaped slug: persist the sanitized value so every
205 203 # reader that builds admin links from the stored option gets a valid WP menu slug.
206 204 if ($raw_slug !== '' && $clean_slug !== $raw_slug) {
207 205 $options = Metasync::get_option();
208 206 $options['general']['white_label_plugin_menu_slug'] = $clean_slug;
@@ -214,9 +212,8 @@
214 212 add_action('admin_menu', array($this, 'add_import_external_data_page'));
215 213 add_action('admin_init', array($this, 'settings_page_init'));
216 214 add_filter('all_plugins', array($this,'metasync_plugin_white_label'));
217 215 add_filter( 'plugin_row_meta',array($this,'metasync_view_detials_url'),10,3);
218 - add_filter('site_transient_update_plugins', array($this, 'inject_whitelabel_icon_into_update_transient'));
219 216
220 217 // Display transient error/success messages for redirections
221 218 add_action('admin_notices', array($this, 'display_redirection_messages'));
222 219
@@ -265,8 +262,9 @@
265 262 add_action('metasync_api_key_changed', array('Metasync_Admin_Navigation', 'invalidate_admin_bar_status_cache'), 10, 0);
266 263
267 264 add_action('admin_init', array($this, 'initialize_cookie'));
268 265 add_action('admin_init', array($this, 'maybe_redirect_to_wizard'));
266 + add_action('admin_init', array($this, 'redirect_legacy_instant_indexing_page'));
269 267
270 268 // Add admin_post hooks for form submissions (WordPress standard way - no output buffering needed)
271 269 add_action('admin_post_metasync_clear_all_cache_plugins', array($this, 'handle_clear_all_cache_plugins'));
272 270 add_action('admin_post_metasync_clear_otto_cache_all', array($this, 'handle_clear_otto_cache_all'));
@@ -284,8 +282,11 @@
284 282
285 283 // Add AJAX for saving execution settings
286 284 add_action( 'wp_ajax_metasync_save_execution_settings', array($this, 'ajax_save_execution_settings') );
287 285
286 + // Add AJAX for saving the Headless Mode demo controls on the Advanced tab
287 + add_action( 'wp_ajax_metasync_save_headless_settings', array(Metasync_Settings_Registration::instance(), 'ajax_save_headless_settings') );
288 +
288 289 // Add AJAX for saving hosting cache settings
289 290 add_action( 'wp_ajax_metasync_save_hosting_cache_settings', array($this, 'ajax_save_hosting_cache_settings') );
290 291
291 292 // Add AJAX for saving OTTO Cache TTL
@@ -315,8 +316,9 @@
315 316 add_action('wp_ajax_metasync_get_default_robots', array($this, 'ajax_get_default_robots'));
316 317 add_action('wp_ajax_metasync_preview_robots_backup', array($this, 'ajax_preview_robots_backup'));
317 318 add_action('wp_ajax_metasync_delete_robots_backup', array($this, 'ajax_delete_robots_backup'));
318 319 add_action('wp_ajax_metasync_restore_robots_backup', array($this, 'ajax_restore_robots_backup'));
320 + add_action('wp_ajax_metasync_get_robots_backups', array($this, 'ajax_get_robots_backups'));
319 321
320 322 // Add AJAX handlers for host blocking test
321 323 add_action('wp_ajax_metasync_test_host_blocking_get', array($this, 'ajax_test_host_blocking_get'));
322 324 add_action('wp_ajax_metasync_test_host_blocking_post', array($this, 'ajax_test_host_blocking_post'));
@@ -370,10 +372,18 @@
370 372 add_action('wp_ajax_metasync_batch_progress', array($this, 'ajax_batch_progress'));
371 373 add_action('wp_ajax_metasync_bulk_optimize_selected', array($this, 'ajax_bulk_optimize_selected'));
372 374 add_action('wp_ajax_metasync_bulk_unoptimize_selected', array($this, 'ajax_bulk_unoptimize_selected'));
373 375 add_action('wp_ajax_metasync_process_batch_tick', array($this, 'ajax_process_batch_tick'));
376 + add_action('wp_ajax_metasync_delete_orphaned_image', array($this, 'ajax_delete_orphaned_image'));
374 377 add_action('metasync_media_batch_optimize_cron', array($this, 'handle_media_batch_cron'));
375 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 +
376 386 # Add AJAX handlers for Google Instant Indexing
377 387 add_action('wp_ajax_metasync_send_giapi', array($this, 'ajax_send_giapi'));
378 388
379 389 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
@@ -466,9 +476,9 @@
466 476
467 477 $data= Metasync::get_option('general');
468 478
469 479 # Get white label menu slug
470 - # Sanitize so a URL-shaped legacy value still yields a valid WP menu slug (WP-413)
480 + # Sanitize so a URL-shaped legacy value still yields a valid WP menu slug
471 481 $menu_slug = empty($data['white_label_plugin_menu_slug']) ? self::$page_slug : sanitize_title($data['white_label_plugin_menu_slug']);
472 482 $menu_slug = $menu_slug === '' ? self::$page_slug : $menu_slug;
473 483
474 484 ?>
@@ -487,23 +497,129 @@
487 497 <?php
488 498 }
489 499
490 500 public function metasync_fouc_prevention_style() {
491 - 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
492 502 return;
493 503 }
494 504 ?>
495 - <style>.metasync-dashboard-wrap { opacity: 0; transition: opacity 0.15s ease-in; }</style>
505 + <style>
506 + .metasync-dashboard-wrap { opacity: 0; transition: opacity 0.15s ease-in; }
507 + /*
508 + * WP prints admin notices at the top of #wpbody-content, then core's
509 + * common.js relocates them next to .wp-header-end inside our wrap on
510 + * DOM-ready. Keep them out of the flow until that move so they don't
511 + * flash above the dashboard header and shove the page down.
512 + * Once relocated they're no longer a direct child here, so this rule
513 + * stops matching and they fade in with the wrap. We mirror core's
514 + * relocation set exactly (.notice/.updated/.error, minus .inline and
515 + * .below-h2); .update-nag is left alone since core never moves it.
516 + */
517 + #wpbody-content > .notice:not(.inline):not(.below-h2),
518 + #wpbody-content > .updated:not(.inline):not(.below-h2),
519 + #wpbody-content > .error:not(.inline):not(.below-h2) { display: none !important; }
520 + </style>
496 521 <?php
497 522 }
498 523
499 524 public function suppress_notices_on_wizard_page() {
500 - if ( isset( $_GET['page'] ) && strpos( $_GET['page'], '-setup-wizard' ) !== false ) {
525 + if ( ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
526 + return;
527 + }
528 +
529 + $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
530 +
531 + // Setup wizard: strip every notice for a fully focused, distraction-free screen.
532 + if ( strpos( $page, '-setup-wizard' ) !== false ) {
501 533 remove_all_actions( 'admin_notices' );
502 534 remove_all_actions( 'all_admin_notices' );
535 + return;
503 536 }
537 +
538 + // Other Search Atlas plugin pages: strip third-party notices but keep our own.
539 + if ( strpos( $page, self::$page_slug ) === 0 ) {
540 + $this->remove_third_party_admin_notices();
541 + }
504 542 }
505 543
544 + /**
545 + * Remove admin-notice callbacks that don't belong to this plugin.
546 + *
547 + * Third-party plugins (e.g. Rank Math, Yoast) register their banners on the
548 + * global admin_notices / all_admin_notices actions, which WordPress fires on
549 + * every admin screen — so their notices leak onto our settings pages. We walk
550 + * the registered callbacks and drop any whose code lives outside this plugin's
551 + * directory, leaving every one of our own notices intact.
552 + */
553 + private function remove_third_party_admin_notices() {
554 + global $wp_filter;
555 +
556 + foreach ( array( 'admin_notices', 'all_admin_notices' ) as $hook ) {
557 + if ( empty( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof WP_Hook ) ) {
558 + continue;
559 + }
560 +
561 + // Collect first, then remove — avoids mutating the callbacks array mid-walk.
562 + $to_remove = array();
563 + foreach ( $wp_filter[ $hook ]->callbacks as $priority => $callbacks ) {
564 + foreach ( $callbacks as $callback ) {
565 + if ( ! $this->is_own_admin_notice_callback( $callback['function'] ) ) {
566 + $to_remove[] = array( $callback['function'], $priority );
567 + }
568 + }
569 + }
570 +
571 + foreach ( $to_remove as $entry ) {
572 + remove_action( $hook, $entry[0], $entry[1] );
573 + }
574 + }
575 + }
576 +
577 + /**
578 + * Determine whether an admin-notice callback is defined by this plugin.
579 + *
580 + * Ownership is decided by where the callback's code physically lives, not by
581 + * class name — our notice owners use inconsistent prefixes (Metasync_Admin,
582 + * Google_Index_Admin, ConfigControllerMetaSync), and a white-label install can
583 + * rename the plugin folder. Resolving the declaring file and checking it sits
584 + * inside this plugin's directory is robust against all of that. If a callback
585 + * can't be introspected, we keep it rather than risk hiding a legitimate notice.
586 + *
587 + * @param string|array|Closure $function The registered callback.
588 + * @return bool True when the callback's code lives inside this plugin.
589 + */
590 + private function is_own_admin_notice_callback( $function ) {
591 + // Plugin root: this file lives in <plugin>/admin, so its parent is the root.
592 + $plugin_dir = wp_normalize_path( dirname( __DIR__ ) ) . '/';
593 +
594 + try {
595 + if ( is_array( $function ) && isset( $function[0], $function[1] ) ) {
596 + $class = is_object( $function[0] ) ? get_class( $function[0] ) : $function[0];
597 + $ref = new ReflectionMethod( $class, $function[1] );
598 + } elseif ( $function instanceof Closure ) {
599 + $ref = new ReflectionFunction( $function );
600 + } elseif ( is_string( $function ) && strpos( $function, '::' ) !== false ) {
601 + list( $class, $method ) = explode( '::', $function, 2 );
602 + $ref = new ReflectionMethod( $class, $method );
603 + } elseif ( is_string( $function ) ) {
604 + $ref = new ReflectionFunction( $function );
605 + } else {
606 + return false;
607 + }
608 + } catch ( \Throwable $e ) {
609 + // Unintrospectable callback — keep it; never hide a notice we can't classify.
610 + return true;
611 + }
612 +
613 + $file = $ref->getFileName();
614 + if ( ! $file ) {
615 + // Internal/built-in callback (no source file) — not ours.
616 + return false;
617 + }
618 +
619 + return strpos( wp_normalize_path( $file ), $plugin_dir ) === 0;
620 + }
621 +
506 622 #---------fixes issue : #95 ----------
507 623 #This function is to redirect in case client changes slug on fresh install
508 624 #It is called by the add_option hook
509 625
@@ -529,9 +645,9 @@
529 645
530 646 #Redirect url to the new slug
531 647 $redirect_url = admin_url('admin.php?page=' . $current_slug);
532 648
533 - wp_redirect($redirect_url);
649 + wp_safe_redirect($redirect_url);
534 650 exit;
535 651 }
536 652 }
537 653
@@ -583,13 +699,13 @@
583 699 * @since 1.0.0
584 700 */
585 701 public function maybe_redirect_to_wizard() {
586 702 // Only redirect if wizard should be shown
587 - 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
588 704 delete_option('metasync_show_wizard');
589 705
590 706 // Don't redirect during AJAX, cron, or bulk activation
591 - 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
592 708 return;
593 709 }
594 710
595 711 // Only redirect if user has access to the plugin
@@ -601,11 +717,56 @@
601 717 exit;
602 718 }
603 719 }
604 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 +
605 765 public function metasync_display_error_log() {
606 766 Metasync_Debug_Manager::instance()->metasync_display_error_log($this);
607 767 }
768 +
608 769 public function metasync_update_wp_config() {
609 770 Metasync_Debug_Manager::instance()->metasync_update_wp_config();
610 771 }
611 772
@@ -648,9 +809,9 @@
648 809 ]);
649 810 $_COOKIE['metasync_previous_slug'] = $new_slug;
650 811 // Redirect to the new slug
651 812 $redirect_url = admin_url('admin.php?page=' . $old_slug);
652 - wp_redirect($redirect_url);
813 + wp_safe_redirect($redirect_url);
653 814 exit;
654 815 }else{
655 816 self::$page_slug = Metasync::get_option('general')['white_label_plugin_menu_slug']=="" ? "searchatlas":Metasync::get_option('general')['white_label_plugin_menu_slug'];
656 817 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
@@ -656,9 +817,9 @@
656 817 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
657 818
658 819 #add redirection for when the old slug is not defined
659 820 #this fixes the redirect issue #
660 - wp_redirect($redirect_url);
821 + wp_safe_redirect($redirect_url);
661 822 exit;
662 823 }
663 824 }
664 825 public function metasync_view_detials_url( $plugin_meta, $plugin_file, $plugin_data ) {
@@ -669,9 +830,10 @@
669 830 if (strpos($meta, 'open-plugin-details-modal') !== false) {
670 831 $meta = sprintf(
671 832 '<a href="%s" class="thickbox open-plugin-details-modal" aria-label="%s" data-title="%s">%s</a>',
672 833 add_query_arg('TB_iframe', 'true', $plugin_uri),
673 - 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'])),
674 836 esc_attr($plugin_data['Name']),
675 837 esc_html__('View details', 'metasync')
676 838 );
677 839 break; // Exit loop after replacing the link
@@ -719,53 +881,8 @@
719 881 return $all_plugins;
720 882 }
721 883
722 884 /**
723 - * Inject the whitelabel icon into the update_plugins transient so that
724 - * /wp-admin/update-core.php (Dashboard → Updates) shows the WL icon
725 - * instead of the SearchAtlas icon returned by the update API.
726 - *
727 - * @param object $transient The site transient object.
728 - * @return object
729 - */
730 - public function inject_whitelabel_icon_into_update_transient($transient) {
731 - if (empty($transient) || !is_object($transient)) {
732 - return $transient;
733 - }
734 -
735 - $general = Metasync::get_option('general');
736 - if (!is_array($general)) {
737 - return $transient;
738 - }
739 -
740 - $icon_url = $general['white_label_plugin_menu_icon'] ?? '';
741 - if (empty($icon_url)) {
742 - return $transient;
743 - }
744 -
745 - $this_plugin = plugin_basename(dirname(__DIR__) . '/metasync.php');
746 -
747 - // Inject into pending updates list
748 - if (!empty($transient->response) && isset($transient->response[$this_plugin])) {
749 - $transient->response[$this_plugin]->icons = [
750 - '1x' => $icon_url,
751 - '2x' => $icon_url,
752 - ];
753 - }
754 -
755 - // Also inject into the "no update needed" list so the icon appears
756 - // on the updates screen even when the plugin is up-to-date
757 - if (!empty($transient->no_update) && isset($transient->no_update[$this_plugin])) {
758 - $transient->no_update[$this_plugin]->icons = [
759 - '1x' => $icon_url,
760 - '2x' => $icon_url,
761 - ];
762 - }
763 -
764 - return $transient;
765 - }
766 -
767 - /**
768 885 * Register the stylesheets for the admin area.
769 886 *
770 887 * @since 1.0.0
771 888 */
@@ -770,9 +887,9 @@
770 887 * @since 1.0.0
771 888 */
772 889 public function enqueue_styles()
773 890 {
774 - $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
775 892
776 893 if (strpos($current_page, self::$page_slug) === 0) {
777 894 wp_enqueue_style(
778 895 $this->plugin_name,
@@ -801,9 +918,9 @@
801 918 );
802 919 }
803 920
804 921 // Enqueue wizard CSS if on wizard page
805 - 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
806 923 wp_enqueue_style(
807 924 $this->plugin_name . '-setup-wizard',
808 925 plugin_dir_url(__FILE__) . 'css/metasync-setup-wizard.css',
809 926 array($this->plugin_name . '-dashboard'),
@@ -812,9 +929,9 @@
812 929 );
813 930 }
814 931
815 932 // Enqueue SEO Health CSS if on the SEO Health page
816 - 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
817 934 wp_enqueue_style(
818 935 $this->plugin_name . '-seo-health',
819 936 plugin_dir_url(__FILE__) . 'css/metasync-seo-health.css',
820 937 array($this->plugin_name . '-dashboard'),
@@ -831,9 +948,9 @@
831 948 */
832 949 public function enqueue_scripts()
833 950 {
834 951 // --- Phase 5 (#887): Extracted inline JS files ---
835 - $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
836 953 $plugin_root_url = plugin_dir_url(dirname(__FILE__));
837 954 $is_metasync_page = ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0);
838 955
839 956 if ($is_metasync_page) {
@@ -863,10 +980,49 @@
863 980 array('jquery'),
864 981 $this->version,
865 982 true
866 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 + );
867 995 }
868 996
997 + $per_page_screens = [
998 + self::$page_slug . '-redirections',
999 + self::$page_slug . '-404-monitor',
1000 + self::$page_slug . '-sync-log',
1001 + self::$page_slug . '-media-optimization',
1002 + ];
1003 +
1004 + if (in_array($current_page, $per_page_screens, true)) {
1005 + // Results-per-page selectors use this external handler instead of
1006 + // an inline event attribute so changing a page size always reloads
1007 + // the current admin page.
1008 + wp_enqueue_script(
1009 + $this->plugin_name . '-per-page',
1010 + plugin_dir_url(__FILE__) . 'js/metasync-per-page.js',
1011 + array(),
1012 + $this->version,
1013 + true
1014 + );
1015 + // Selector navigation is a GET, so the per-page nonce rides along
1016 + // as a query parameter and gates the user_meta persist in
1017 + // Metasync_Per_Page_Helper::resolve().
1018 + wp_localize_script(
1019 + $this->plugin_name . '-per-page',
1020 + 'metasyncPerPage',
1021 + array('nonces' => Metasync_Per_Page_Helper::page_nonces())
1022 + );
1023 + }
1024 +
869 1025 // Dashboard iframe height (only on dashboard page)
870 1026 if ($current_page === self::$page_slug . '-dashboard' || $current_page === self::$page_slug) {
871 1027 wp_enqueue_script(
872 1028 $this->plugin_name . '-iframe',
@@ -915,12 +1071,12 @@
915 1071 true
916 1072 );
917 1073 // Pass the active tab from server (handles POST redirect)
918 1074 $sitemap_active_tab = 'general';
919 - if (isset($_GET['tab']) && in_array($_GET['tab'], ['general', 'news', 'video'], true)) {
920 - $sitemap_active_tab = sanitize_text_field(wp_unslash($_GET['tab']));
921 - } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) {
922 - $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
923 1079 }
924 1080 wp_localize_script($this->plugin_name . '-sitemap-tabs', 'metasyncSitemapTabs', [
925 1081 'activeTab' => $sitemap_active_tab,
926 1082 ]);
@@ -1051,9 +1207,8 @@
1051 1207 true
1052 1208 );
1053 1209 wp_localize_script($this->plugin_name . '-connect', 'metasyncConnectData', array(
1054 1210 'optionKey' => self::option_key,
1055 - 'storedPassword' => isset($whitelabel_data['settings_password']) ? $whitelabel_data['settings_password'] : '',
1056 1211 'adminPostUrl' => admin_url('admin-post.php'),
1057 1212 'exportNonce' => wp_create_nonce('metasync_export_whitelabel'),
1058 1213 'ajaxUrl' => admin_url('admin-ajax.php'),
1059 1214 'logoutNonceField' => wp_nonce_field('whitelabel_logout_nonce', 'whitelabel_logout_nonce', true, false),
@@ -1249,8 +1404,10 @@
1249 1404 'nonce' => wp_create_nonce('metasync_nonce'),
1250 1405 'sa_connect_nonce' => $sa_connect_nonce,
1251 1406 'reset_auth_nonce' => wp_create_nonce('metasync_reset_auth_nonce'),
1252 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'),
1253 1410 'heartbeat_state' => $heartbeat_state,
1254 1411 'dashboard_domain' => self::get_effective_dashboard_domain(),
1255 1412 'support_email' => Metasync::SUPPORT_EMAIL,
1256 1413 'documentation_domain' => Metasync::DOCUMENTATION_DOMAIN,
@@ -1314,9 +1471,9 @@
1314 1471 add_action('admin_notices', array($this, 'display_page_builder_notice'));
1315 1472 // Display update warning banner if plugin update is available
1316 1473 add_action('admin_notices', array($this, 'display_update_warning_banner'));
1317 1474 // Enqueue wizard assets if on wizard page
1318 - 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
1319 1476 wp_enqueue_script(
1320 1477 $this->plugin_name . '-setup-wizard',
1321 1478 plugin_dir_url(__FILE__) . 'js/metasync-setup-wizard.js',
1322 1479 array('jquery'),
@@ -1340,14 +1497,44 @@
1340 1497 }
1341 1498
1342 1499 /**
1343 1500 * Settings of HeartBeat API for admin area.
1344 - * 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.
1345 1519 */
1346 1520 function metasync_heartbeat_settings($settings)
1347 1521 {
1348 - global $heartbeat_frequency;
1349 - $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;
1350 1537 return $settings;
1351 1538 }
1352 1539
1353 1540 /**
@@ -1485,13 +1672,8 @@
1485 1672 {
1486 1673 Metasync_Connect_Manager::instance()->test_searchatlas_ajax_endpoint();
1487 1674 }
1488 1675
1489 - public function simple_ajax_test()
1490 - {
1491 - Metasync_Connect_Manager::instance()->simple_ajax_test();
1492 - }
1493 -
1494 1676 /**
1495 1677 * Create Admin Dashboard Iframe Page
1496 1678 * Embeds the Search Atlas dashboard directly in WordPress admin
1497 1679 */
@@ -1517,12 +1699,12 @@
1517 1699 $cache_count = Metasync_Otto_Transient_Cache::get_cache_count();
1518 1700
1519 1701 ?>
1520 1702 <!-- OTTO Cache TTL Setting -->
1521 - <div style="margin-bottom: 30px;">
1522 - <h4 style="margin-top: 0; color: var(--dashboard-text-primary);">OTTO Cache TTL</h4>
1703 + <div style="margin-bottom: 30px; padding-top: 20px;">
1704 + <h4 style="margin-top: 0; color: var(--dashboard-text-primary);"><?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> Cache TTL</h4>
1523 1705 <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">
1524 - 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.
1525 1707 </p>
1526 1708
1527 1709 <div style="background: rgba(255,255,255,0.02); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
1528 1710 <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
@@ -1528,8 +1710,9 @@
1528 1710 <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
1529 1711 <label for="metasync-otto-cache-ttl" style="color: var(--dashboard-text-primary); font-weight: 500;">
1530 1712 Cache TTL (minutes):
1531 1713 </label>
1714 + <?php Metasync::render_tooltip_icon('otto_cache_ttl', 'How many minutes OTTO reuses its last SEO suggestions before fetching fresh ones. 30 is fine for most sites.'); ?>
1532 1715 <input type="number"
1533 1716 id="metasync-otto-cache-ttl"
1534 1717 value="<?php echo esc_attr($this->get_otto_cache_ttl_minutes()); ?>"
1535 1718 min="30"
@@ -1550,9 +1733,9 @@
1550 1733 </button>
1551 1734 <span id="metasync-otto-ttl-save-msg" style="display: none; font-size: 13px;"></span>
1552 1735 </div>
1553 1736
1554 - <input type="hidden" id="metasync-otto-ttl-nonce" value="<?php echo wp_create_nonce('metasync_otto_cache_ttl_nonce'); ?>" />
1737 + <input type="hidden" id="metasync-otto-ttl-nonce" value="<?php echo esc_attr(wp_create_nonce('metasync_otto_cache_ttl_nonce')); ?>" />
1555 1738 </div>
1556 1739 </div>
1557 1740
1558 1741 <!-- Cache Plugin Management -->
@@ -1577,9 +1760,9 @@
1577 1760 } else {
1578 1761 echo '<p style="color: var(--dashboard-text-secondary);">ℹ️ No cache plugins detected.</p>';
1579 1762 }
1580 1763 } catch (Exception $e) {
1581 - 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
1582 1765 echo '<p style="color: var(--dashboard-error);">⚠️ An error occurred while retrieving cache plugin status.</p>';
1583 1766 }
1584 1767 } else {
1585 1768 echo '<p style="color: var(--dashboard-error);">⚠️ Cache Purge class not loaded.</p>';
@@ -1596,12 +1779,12 @@
1596 1779 </form>
1597 1780
1598 1781 <?php
1599 1782 // Display success/error messages
1600 - if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') {
1601 - $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0;
1602 - $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0;
1603 - $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
1604 1787
1605 1788 if ($cleared > 0) {
1606 1789 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1607 1790 echo '✅ <strong>Success!</strong> Cleared cache for ' . intval( $cleared ) . ' plugin(s)';
@@ -1621,10 +1804,10 @@
1621 1804 echo '</p></div>';
1622 1805 }
1623 1806 }
1624 1807
1625 - if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') {
1626 - $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
1627 1810 if (empty($message)) {
1628 1811 $message = 'An unknown error occurred while clearing cache. Please check error logs for details.';
1629 1812 }
1630 1813 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
@@ -1673,8 +1856,9 @@
1673 1856 <?php checked(true, !empty($hosting_settings['wpengine_enabled'])); ?>
1674 1857 <?php echo !$wpe_detected ? 'disabled' : ''; ?>
1675 1858 style="width: 16px; height: 16px; cursor: <?php echo $wpe_detected ? 'pointer' : 'not-allowed'; ?>;" />
1676 1859 <span style="color: var(--dashboard-text-primary); font-weight: 500;">WP Engine</span>
1860 + <?php Metasync::render_tooltip_icon('hosting_cache_wpengine', 'If hosted on WP Engine, turn on so OTTO clears the host\'s built-in cache when it updates a page.'); ?>
1677 1861 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges Varnish + Memcached</span>
1678 1862 </label>
1679 1863
1680 1864 <label style="display: flex; align-items: center; gap: 10px; margin-bottom: 16px; cursor: pointer;">
@@ -1683,8 +1867,9 @@
1683 1867 <?php checked(true, !empty($hosting_settings['kinsta_enabled'])); ?>
1684 1868 <?php echo !$kinsta_detected ? 'disabled' : ''; ?>
1685 1869 style="width: 16px; height: 16px; cursor: <?php echo $kinsta_detected ? 'pointer' : 'not-allowed'; ?>;" />
1686 1870 <span style="color: var(--dashboard-text-primary); font-weight: 500;">Kinsta</span>
1871 + <?php Metasync::render_tooltip_icon('hosting_cache_kinsta', 'If hosted on Kinsta, turn on so OTTO clears Kinsta\'s server cache automatically.'); ?>
1687 1872 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges full-page cache (kinsta_cache_purge_full)</span>
1688 1873 </label>
1689 1874
1690 1875 <div style="display: flex; align-items: center; gap: 12px;">
@@ -1698,9 +1883,9 @@
1698 1883 </button>
1699 1884 <span id="metasync-hc-save-msg" style="display: none; font-size: 13px;"></span>
1700 1885 </div>
1701 1886
1702 - <input type="hidden" id="metasync-hc-nonce" value="<?php echo wp_create_nonce('metasync_hosting_cache_nonce'); ?>" />
1887 + <input type="hidden" id="metasync-hc-nonce" value="<?php echo esc_attr(wp_create_nonce('metasync_hosting_cache_nonce')); ?>" />
1703 1888 </div>
1704 1889
1705 1890 <!-- Purge button -->
1706 1891 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
@@ -1719,12 +1904,13 @@
1719 1904 </form>
1720 1905
1721 1906 <?php
1722 1907 // Hosting cache result messages
1723 - if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') {
1724 - $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode($_GET['hc_cleared'])) : '';
1725 - $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode($_GET['hc_failed'])) : '';
1726 - $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
1727 1913
1728 1914 if ($hc_cleared) {
1729 1915 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1730 1916 echo '✅ <strong>Success!</strong> Purged hosting cache on: ' . esc_html($hc_cleared);
@@ -1739,8 +1925,9 @@
1739 1925 echo '<div class="notice notice-info inline" style="margin-top: 10px;"><p>';
1740 1926 echo 'ℹ️ No enabled hosting providers were detected on this server (' . esc_html($hc_not_detected) . ').';
1741 1927 echo '</p></div>';
1742 1928 }
1929 + // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1743 1930 }
1744 1931 ?>
1745 1932
1746 1933 <script>
@@ -1832,9 +2019,9 @@
1832 2019 id="metasync-targeted-object-cache"
1833 2020 <?php checked('1', $targeted_cache_enabled); ?>
1834 2021 style="width: 16px; height: 16px; cursor: pointer; margin-top: 2px; flex-shrink: 0;" />
1835 2022 <span>
1836 - <span style="color: var(--dashboard-text-primary); font-weight: 500; display: block; margin-bottom: 4px;">Targeted Object Cache Purge</span>
2023 + <span style="color: var(--dashboard-text-primary); font-weight: 500; display: block; margin-bottom: 4px;">Targeted Object Cache Purge<?php Metasync::render_tooltip_icon('targeted_object_cache_purge', 'Leave this on. Refreshes only the pages OTTO changed instead of wiping your whole site\'s memory cache — safer/faster on large sites.'); ?></span>
1837 2024 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">
1838 2025 When enabled, only the updated posts are evicted from the object cache (recommended for large sites).
1839 2026 When disabled, a full <code>wp_cache_flush()</code> is used instead.
1840 2027 </span>
@@ -1905,11 +2092,11 @@
1905 2092 </div>
1906 2093
1907 2094 <?php
1908 2095 // Display success/error messages
1909 - if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') {
1910 - $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0;
1911 - $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
1912 2099
1913 2100 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1914 2101 if (!empty($url)) {
1915 2102 echo '✅ <strong>Success!</strong> Cleared cache for URL: <code>' . esc_html($url) . '</code> (' . intval( $cleared_count ) . ' entries)';
@@ -1918,10 +2105,10 @@
1918 2105 }
1919 2106 echo '</p></div>';
1920 2107 }
1921 2108
1922 - if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') {
1923 - $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
1924 2111 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
1925 2112 echo '❌ <strong>Error:</strong> ' . esc_html($message);
1926 2113 echo '</p></div>';
1927 2114 }
@@ -1960,9 +2147,9 @@
1960 2147 <td>
1961 2148 <input type="url"
1962 2149 id="otto_cache_url"
1963 2150 name="otto_cache_url"
1964 - 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 ?>"
1965 2152 class="regular-text"
1966 2153 placeholder="https://example.com/page/"
1967 2154 required />
1968 2155 <p class="description" style="color: var(--dashboard-text-secondary);">Enter the full URL of the page whose cache you want to clear.</p>
@@ -2033,9 +2220,10 @@
2033 2220 /**
2034 2221 * AJAX handler for saving hosting cache settings
2035 2222 */
2036 2223 public function ajax_save_hosting_cache_settings() {
2037 - 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')) {
2038 2226 wp_send_json_error(array('message' => 'Security check failed'));
2039 2227 return;
2040 2228 }
2041 2229
@@ -2056,9 +2244,10 @@
2056 2244 /**
2057 2245 * AJAX handler: save object cache behaviour settings
2058 2246 */
2059 2247 public function ajax_save_object_cache_settings() {
2060 - 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')) {
2061 2250 wp_send_json_error(array('message' => 'Security check failed'));
2062 2251 return;
2063 2252 }
2064 2253
@@ -2085,9 +2274,10 @@
2085 2274 /**
2086 2275 * AJAX handler for saving OTTO Cache TTL
2087 2276 */
2088 2277 public function ajax_save_otto_cache_ttl() {
2089 - 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')) {
2090 2280 wp_send_json_error(array('message' => 'Security check failed.'));
2091 2281 return;
2092 2282 }
2093 2283
@@ -2098,9 +2288,9 @@
2098 2288
2099 2289 $ttl = isset($_POST['otto_cache_ttl']) ? absint($_POST['otto_cache_ttl']) : 30;
2100 2290
2101 2291 if ($ttl < 30 || $ttl > 1440) {
2102 - wp_send_json_error(array('message' => 'OTTO Cache TTL must be between 30 and 1440 minutes.'));
2292 + wp_send_json_error(array('message' => sprintf('%s Cache TTL must be between 30 and 1440 minutes.', Metasync::get_whitelabel_otto_name())));
2103 2293 return;
2104 2294 }
2105 2295
2106 2296 $settings = get_option('metasync_execution_settings', array());
@@ -2106,9 +2296,9 @@
2106 2296 $settings = get_option('metasync_execution_settings', array());
2107 2297 $settings['otto_cache_ttl'] = $ttl;
2108 2298 update_option('metasync_execution_settings', $settings);
2109 2299
2110 - wp_send_json_success(array('message' => 'OTTO Cache TTL saved.'));
2300 + wp_send_json_success(array('message' => sprintf('%s Cache TTL saved.', Metasync::get_whitelabel_otto_name())));
2111 2301 }
2112 2302
2113 2303 /**
2114 2304 * admin_post handler: purge WP Engine and Kinsta hosting-level caches
@@ -2113,9 +2303,10 @@
2113 2303 /**
2114 2304 * admin_post handler: purge WP Engine and Kinsta hosting-level caches
2115 2305 */
2116 2306 public function handle_purge_hosting_cache() {
2117 - 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')) {
2118 2309 wp_die('Security check failed');
2119 2310 }
2120 2311
2121 2312 if (!Metasync::current_user_has_plugin_access()) {
@@ -2135,9 +2326,9 @@
2135 2326 WpeCommon::purge_varnish_cache();
2136 2327 WpeCommon::purge_memcached();
2137 2328 $cleared[] = 'WP Engine';
2138 2329 } catch (Exception $e) {
2139 - 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
2140 2331 $failed[] = 'WP Engine';
2141 2332 }
2142 2333 } else {
2143 2334 $not_detected[] = 'WP Engine';
@@ -2150,9 +2341,9 @@
2150 2341 try {
2151 2342 KinstaCache::get_instance()->kinsta_cache_purge_full();
2152 2343 $cleared[] = 'Kinsta';
2153 2344 } catch (Exception $e) {
2154 - 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
2155 2346 $failed[] = 'Kinsta';
2156 2347 }
2157 2348 } else {
2158 2349 $not_detected[] = 'Kinsta';
@@ -2547,9 +2738,9 @@
2547 2738 # Initialize MetaSync API request class and trigger synchronization
2548 2739 (new Metasync_Sync_Requests())->SyncCustomerParams();
2549 2740 } catch (Exception $e) {
2550 2741 # Log any API request errors for debugging
2551 - 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
2552 2743 }
2553 2744 }
2554 2745
2555 2746 /*
@@ -2564,9 +2755,9 @@
2564 2755 # Initialize MetaSync API request class and trigger synchronization
2565 2756 (new Metasync_Sync_Requests())->SyncCustomerParams();
2566 2757 } catch (Exception $e) {
2567 2758 # Log any API request errors for debugging
2568 - 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
2569 2760 }
2570 2761 }
2571 2762
2572 2763 /**
@@ -2599,8 +2790,15 @@
2599 2790 // Handle form submissions
2600 2791 if (isset($_POST['metasync_media_optimization_nonce'])) {
2601 2792 check_admin_referer('metasync_save_media_optimization', 'metasync_media_optimization_nonce');
2602 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 +
2603 2801 // Handle reset to defaults
2604 2802 if (!empty($_POST['metasync_media_reset'])) {
2605 2803 $defaults = Metasync_Media_Settings::get_defaults();
2606 2804 Metasync_Media_Settings::save_settings($defaults);
@@ -2605,8 +2803,9 @@
2605 2803 $defaults = Metasync_Media_Settings::get_defaults();
2606 2804 Metasync_Media_Settings::save_settings($defaults);
2607 2805 $save_success = true;
2608 2806 } elseif (isset($_POST['metasync_media'])) {
2807 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Media_Settings::save_settings().
2609 2808 $input = wp_unslash($_POST['metasync_media']);
2610 2809 Metasync_Media_Settings::save_settings($input);
2611 2810 $save_success = true;
2612 2811 }
@@ -2638,8 +2837,15 @@
2638 2837 * Code Minification page callback
2639 2838 */
2640 2839 public function create_admin_code_minification_page()
2641 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 +
2642 2848 $this->render_layout_open('Code Minification', 'code_minification', 'Minify CSS, JavaScript, and HTML to improve performance.');
2643 2849 // Load settings and compatibility classes
2644 2850 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-settings.php';
2645 2851 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-cache.php';
@@ -2656,8 +2862,9 @@
2656 2862 $defaults = Metasync_Minification_Settings::get_defaults();
2657 2863 Metasync_Minification_Settings::save_settings($defaults);
2658 2864 $save_success = true;
2659 2865 } elseif (isset($_POST['metasync_code_min'])) {
2866 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Minification_Settings::save_settings().
2660 2867 $input = (array) wp_unslash($_POST['metasync_code_min']);
2661 2868 Metasync_Minification_Settings::save_settings($input);
2662 2869 $save_success = true;
2663 2870 }
@@ -2690,8 +2897,15 @@
2690 2897 if (!$attachment_id) {
2691 2898 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2692 2899 }
2693 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 +
2694 2908 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-settings.php';
2695 2909 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-image-converter.php';
2696 2910 $settings = Metasync_Media_Settings::get_settings();
2697 2911
@@ -2705,8 +2919,9 @@
2705 2919 $max_bytes = 10 * 1024 * 1024;
2706 2920 if (filesize($file) > $max_bytes) {
2707 2921 $size_mb = round(filesize($file) / 1024 / 1024, 1);
2708 2922 wp_send_json_error(sprintf(
2923 + /* translators: %s: file size in megabytes. */
2709 2924 __('Optimization skipped: file size (%s MB) exceeds the 10 MB safety limit to prevent memory issues.', 'metasync'),
2710 2925 $size_mb
2711 2926 ));
2712 2927 }
@@ -2743,8 +2958,20 @@
2743 2958 if (!$attachment_id) {
2744 2959 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2745 2960 }
2746 2961
2962 + // Ownership: reverting deletes the attachment's converted files, so
2963 + // restrict it to users who may edit this specific attachment.
2964 + if (!current_user_can('edit_post', $attachment_id)) {
2965 + wp_send_json_error(__('Permission denied.', 'metasync'));
2966 + }
2967 +
2968 + // Replace-strategy conversions have no original to restore — reverting
2969 + // would delete the attachment's only file. Refuse up front, like bulk revert does.
2970 + if (!Metasync_Image_Converter::can_revert($attachment_id)) {
2971 + wp_send_json_error(__('This image cannot be reverted. The original file no longer exists (replace strategy).', 'metasync'));
2972 + }
2973 +
2747 2974 $success = Metasync_Image_Converter::revert_attachment($attachment_id);
2748 2975
2749 2976 if ($success) {
2750 2977 wp_send_json_success();
@@ -2796,8 +3023,15 @@
2796 3023 public function ajax_batch_progress()
2797 3024 {
2798 3025 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
2799 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 +
2800 3034 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-batch-optimizer.php';
2801 3035 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
2802 3036
2803 3037 $progress = Metasync_Media_Batch_Optimizer::get_progress();
@@ -2828,10 +3062,19 @@
2828 3062 $settings = Metasync_Media_Settings::get_settings();
2829 3063
2830 3064 $success = 0;
2831 3065 $failed = 0;
3066 + $denied = 0;
2832 3067
2833 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 +
2834 3077 if (Metasync_Image_Converter::convert_attachment($id, $settings)) {
2835 3078 $success++;
2836 3079 } else {
2837 3080 $failed++;
@@ -2840,8 +3083,9 @@
2840 3083
2841 3084 wp_send_json_success([
2842 3085 'success' => $success,
2843 3086 'failed' => $failed,
3087 + 'denied' => $denied,
2844 3088 ]);
2845 3089 }
2846 3090
2847 3091 /**
@@ -2864,11 +3108,19 @@
2864 3108
2865 3109 $success = 0;
2866 3110 $failed = 0;
2867 3111 $skipped = 0;
3112 + $denied = 0;
2868 3113 $errors = [];
2869 3114
2870 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 +
2871 3123 $format = get_post_meta($id, '_metasync_converted_format', true);
2872 3124
2873 3125 if (!$format) {
2874 3126 $skipped++;
@@ -2878,8 +3130,9 @@
2878 3130 if (!Metasync_Image_Converter::can_revert($id)) {
2879 3131 $skipped++;
2880 3132 $file = get_attached_file($id);
2881 3133 $name = $file ? basename($file) : "ID {$id}";
3134 + /* translators: %s: attachment file name. */
2882 3135 $errors[] = sprintf(__('%s: skipped — original image unavailable.', 'metasync'), $name);
2883 3136 continue;
2884 3137 }
2885 3138
@@ -2888,8 +3141,9 @@
2888 3141 } else {
2889 3142 $failed++;
2890 3143 $file = get_attached_file($id);
2891 3144 $name = $file ? basename($file) : "ID {$id}";
3145 + /* translators: %s: attachment file name. */
2892 3146 $errors[] = sprintf(__('%s: revert failed (original file may not exist).', 'metasync'), $name);
2893 3147 }
2894 3148 }
2895 3149
@@ -2896,8 +3150,9 @@
2896 3150 wp_send_json_success([
2897 3151 'success' => $success,
2898 3152 'failed' => $failed,
2899 3153 'skipped' => $skipped,
3154 + 'denied' => $denied,
2900 3155 'errors' => $errors,
2901 3156 ]);
2902 3157 }
2903 3158
@@ -2921,8 +3176,49 @@
2921 3176 wp_send_json_success($progress);
2922 3177 }
2923 3178
2924 3179 /**
3180 + * AJAX: Delete an orphaned media record (attachment whose file is missing).
3181 + *
3182 + * Only deletes when the file is confirmed missing on disk, so valid
3183 + * attachments can never be removed through this endpoint.
3184 + */
3185 + public function ajax_delete_orphaned_image()
3186 + {
3187 + check_ajax_referer('metasync_media_opt_nonce', 'nonce');
3188 +
3189 + $attachment_id = isset($_POST['attachment_id']) ? absint($_POST['attachment_id']) : 0;
3190 + if (!$attachment_id) {
3191 + wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
3192 + }
3193 +
3194 + if (!current_user_can('delete_post', $attachment_id)) {
3195 + wp_send_json_error(__('Permission denied.', 'metasync'));
3196 + }
3197 +
3198 + if (get_post_type($attachment_id) !== 'attachment') {
3199 + wp_send_json_error(__('Not an attachment.', 'metasync'));
3200 + }
3201 +
3202 + require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
3203 +
3204 + // Guard: only orphaned records (missing file) may be deleted here.
3205 + if (!Metasync_Media_Library_List_Table::is_file_missing($attachment_id)) {
3206 + wp_send_json_error(__('The image file exists; refusing to delete a valid attachment.', 'metasync'));
3207 + }
3208 +
3209 + $deleted = wp_delete_attachment($attachment_id, true);
3210 +
3211 + if ($deleted) {
3212 + wp_send_json_success([
3213 + 'stats' => Metasync_Media_Library_List_Table::get_stats(),
3214 + ]);
3215 + }
3216 +
3217 + wp_send_json_error(__('Failed to delete the orphaned media record.', 'metasync'));
3218 + }
3219 +
3220 + /**
2925 3221 * Cron handler: Process batch optimization tick.
2926 3222 */
2927 3223 public function handle_media_batch_cron()
2928 3224 {
@@ -2932,8 +3228,86 @@
2932 3228 Metasync_Media_Batch_Optimizer::process_batch_tick();
2933 3229 }
2934 3230
2935 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 + /**
2936 3310 * Report Issue page callback
2937 3311 */
2938 3312 public function create_admin_report_issue_page()
2939 3313 {
@@ -2945,8 +3319,17 @@
2945 3319 * XML Sitemap page callback
2946 3320 */
2947 3321 public function create_admin_xml_sitemap_page()
2948 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 +
2949 3332 // Load sitemap generator class
2950 3333 require_once plugin_dir_path(dirname(__FILE__)) . 'sitemap/class-metasync-sitemap-generator.php';
2951 3334
2952 3335 $sitemap_generator = new Metasync_Sitemap_Generator();
@@ -2977,10 +3360,13 @@
2977 3360 // Regenerate sitemap with new content settings
2978 3361 $result = $sitemap_generator->generate_sitemap();
2979 3362 if (is_wp_error($result)) {
2980 3363 echo '<div class="notice notice-error"><p>' . esc_html(
3364 + /* translators: %s: error message. */
2981 3365 sprintf(__('Settings saved but sitemap generation failed: %s', 'metasync'), $result->get_error_message())
2982 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>';
2983 3369 } else {
2984 3370 echo '<div class="notice notice-success"><p>' . esc_html__('Sitemap content settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
2985 3371 }
2986 3372 }
@@ -2991,10 +3377,10 @@
2991 3377
2992 3378 // Build generic taxonomy filters
2993 3379 $news_taxonomies = [];
2994 3380 if (!empty($_POST['news_taxonomies']) && is_array($_POST['news_taxonomies'])) {
2995 - foreach ($_POST['news_taxonomies'] as $tax_name => $term_ids) {
2996 - $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));
2997 3383 }
2998 3384 }
2999 3385
3000 3386 $news_settings = [
@@ -3019,9 +3405,9 @@
3019 3405 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3020 3406 } else {
3021 3407 // Also remove physical file if it exists
3022 3408 if (file_exists(ABSPATH . 'news-sitemap.xml')) {
3023 - @unlink(ABSPATH . 'news-sitemap.xml');
3409 + @wp_delete_file(ABSPATH . 'news-sitemap.xml');
3024 3410 }
3025 3411 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
3026 3412 }
3027 3413 }
@@ -3032,10 +3418,10 @@
3032 3418
3033 3419 // Build generic taxonomy filters
3034 3420 $video_taxonomies = [];
3035 3421 if (!empty($_POST['video_taxonomies']) && is_array($_POST['video_taxonomies'])) {
3036 - foreach ($_POST['video_taxonomies'] as $tax_name => $term_ids) {
3037 - $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));
3038 3424 }
3039 3425 }
3040 3426
3041 3427 $video_settings = [
@@ -3043,8 +3429,12 @@
3043 3429 'post_types' => array_map('sanitize_key', (array) ($_POST['video_post_types'] ?? ['post', 'page'])),
3044 3430 'auto_detect' => isset($_POST['auto_detect']),
3045 3431 'taxonomies' => $video_taxonomies,
3046 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'] ?? '')),
3047 3437 ];
3048 3438
3049 3439 // Always invalidate old cache before saving new settings
3050 3440 delete_transient('metasync_vsm_' . md5('video-sitemap.xml'));
@@ -3057,9 +3447,9 @@
3057 3447 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3058 3448 } else {
3059 3449 // Also remove physical file if it exists
3060 3450 if (file_exists(ABSPATH . 'video-sitemap.xml')) {
3061 - @unlink(ABSPATH . 'video-sitemap.xml');
3451 + @wp_delete_file(ABSPATH . 'video-sitemap.xml');
3062 3452 }
3063 3453 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
3064 3454 }
3065 3455 }
@@ -3102,8 +3492,17 @@
3102 3492 if ($sitemap_generator->generate_news_sitemap()) {
3103 3493 $extras[] = 'news';
3104 3494 }
3105 3495 }
3496 +
3497 + // Reclaim unreferenced memory between the news and video passes
3498 + // so the video generator starts with more headroom (they share
3499 + // one request). Each generator already releases its own object
3500 + // cache via clean_post_cache(), so no cache flush is needed here.
3501 + if (function_exists('gc_collect_cycles')) {
3502 + gc_collect_cycles();
3503 + }
3504 +
3106 3505 if (!empty($video_opts['enabled'])) {
3107 3506 if ($sitemap_generator->generate_video_sitemap()) {
3108 3507 $extras[] = 'video';
3109 3508 }
@@ -3108,21 +3507,30 @@
3108 3507 $extras[] = 'video';
3109 3508 }
3110 3509 }
3111 3510
3511 + // Same inter-pass GC before the main sitemap pass begins.
3512 + if (function_exists('gc_collect_cycles')) {
3513 + gc_collect_cycles();
3514 + }
3515 +
3112 3516 // Generate main sitemap (its index will include news/video since they now exist)
3113 3517 $result = $sitemap_generator->generate_sitemap();
3114 3518
3115 3519 if (is_wp_error($result)) {
3116 3520 $error_msg = $result->get_error_message();
3117 - 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
3118 3522 echo '<div class="notice notice-error"><p>' . esc_html(
3523 + /* translators: %s: error message. */
3119 3524 sprintf(__('Sitemap generation failed: %s', 'metasync'), $error_msg)
3120 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>';
3121 3528 } else {
3122 3529 $message = esc_html__('Sitemap generated successfully!', 'metasync');
3123 3530 if (!empty($extras)) {
3124 3531 $message .= ' ' . sprintf(
3532 + /* translators: %s: list of sitemap types. */
3125 3533 esc_html__('Also generated %s sitemap(s).', 'metasync'),
3126 3534 implode(' & ', $extras)
3127 3535 );
3128 3536 }
@@ -3157,9 +3565,9 @@
3157 3565
3158 3566 if ($deleted) {
3159 3567 // Disable auto-update only when the general sitemap is removed
3160 3568 update_option('metasync_sitemap_auto_update', false);
3161 - // Re-enable WP core sitemap only if no other MetaSync sitemaps remain (WP-396)
3569 + // Re-enable WP core sitemap only if no other MetaSync sitemaps remain
3162 3570 if (!$sitemap_generator->sitemap_exists()) {
3163 3571 delete_option('metasync_disable_wp_sitemap');
3164 3572 }
3165 3573 echo '<div class="notice notice-success"><p>' . esc_html__('General sitemap deleted successfully!', 'metasync') . '</p></div>';
@@ -3188,9 +3596,9 @@
3188 3596
3189 3597 if ($deleted) {
3190 3598 // Also disable auto-update when deleting
3191 3599 update_option('metasync_sitemap_auto_update', false);
3192 - // Re-enable WP core sitemap so the site isn't left with zero sitemaps (WP-396)
3600 + // Re-enable WP core sitemap so the site isn't left with zero sitemaps
3193 3601 delete_option('metasync_disable_wp_sitemap');
3194 3602 echo '<div class="notice notice-success"><p>' . esc_html__('All sitemaps deleted successfully!', 'metasync') . '</p></div>';
3195 3603 } else {
3196 3604 echo '<div class="notice notice-error"><p>' . esc_html__('Failed to delete sitemaps. The files may not exist or are not writable.', 'metasync') . '</p></div>';
@@ -3311,47 +3719,8 @@
3311 3719 Metasync_Admin_Pages::get_instance($this)->create_admin_breadcrumbs_page();
3312 3720 }
3313 3721
3314 3722 /**
3315 - * Google Instant Index Setting page callback
3316 - */
3317 - public function create_admin_google_instant_index_page()
3318 - {
3319 - $this->render_layout_open('Instant Indexing', 'instant_index', 'Submit URLs to Google for instant indexing via the Indexing API.');
3320 -
3321 - // Render shared Google Index credentials section
3322 - if (!function_exists('google_index_direct')) {
3323 - if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) {
3324 - require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
3325 - } else {
3326 - error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php');
3327 - return;
3328 - }
3329 - }
3330 - $google_index = google_index_direct();
3331 - $service_info = $google_index->get_service_account_info();
3332 - $is_configured = !isset($service_info['error']);
3333 -
3334 - $saved_json_display = $is_configured ? $google_index->get_redacted_config_json() : '';
3335 -
3336 - include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-index-api-settings.php';
3337 -
3338 - // Render post types selection with save form
3339 - $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
3340 - $post_types_settings = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
3341 - ?>
3342 - <form method="POST" action="">
3343 - <?php include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-instant-post-types.php'; ?>
3344 - <div class="dashboard-card" style="padding: 20px;">
3345 - <?php submit_button('Save Post Types', 'primary', 'submit', false, array('class' => 'button button-primary')); ?>
3346 - </div>
3347 - </form>
3348 - <?php
3349 -
3350 - $this->render_layout_close();
3351 - }
3352 -
3353 - /**
3354 3723 * Google Console page callback
3355 3724 */
3356 3725 public function create_admin_google_console_page()
3357 3726 {
@@ -3374,16 +3743,8 @@
3374 3743 $this->render_layout_close();
3375 3744 }
3376 3745
3377 3746 /**
3378 - * General Options page callback
3379 - */
3380 - public function create_admin_optimal_settings_page()
3381 - {
3382 - Metasync_Admin_Pages::get_instance($this)->create_admin_optimal_settings_page();
3383 - }
3384 -
3385 - /**
3386 3747 * Global Options page callback
3387 3748 */
3388 3749 public function create_admin_global_settings_page()
3389 3750 {
@@ -3415,16 +3776,8 @@
3415 3776 Metasync_Admin_Pages::get_instance($this)->create_admin_seo_controls_page();
3416 3777 }
3417 3778
3418 3779 /**
3419 - * Site Optimal Settings page callback
3420 - */
3421 - public function optimization_settings_options()
3422 - {
3423 - Metasync_Admin_Pages::get_instance($this)->optimization_settings_options();
3424 - }
3425 -
3426 - /**
3427 3780 * redirection page callback with tabs
3428 3781 */
3429 3782 public function create_admin_redirections_page()
3430 3783 {
@@ -3451,10 +3804,11 @@
3451 3804 }
3452 3805 delete_transient( Metasync_CPU_Monitor::DEFER_NOTICE_TRANSIENT );
3453 3806 echo '<div class="notice notice-warning is-dismissible"><p>';
3454 3807 printf(
3455 - /* translators: 1: current load, 2: threshold, 3: core count */
3456 - esc_html__( 'MetaSync: Batch processing was deferred — server CPU load (%1$s) exceeded the threshold (%2$s on %3$s cores). Processing will resume automatically.', 'metasync' ),
3808 + /* translators: 1: plugin name, 2: current load, 3: threshold, 4: core count */
3809 + esc_html__( '%1$s: Batch processing was deferred — server CPU load (%2$s) exceeded the threshold (%3$s on %4$s cores). Processing will resume automatically.', 'metasync' ),
3810 + esc_html( Metasync::get_effective_plugin_name() ),
3457 3811 esc_html( $data['load'] ),
3458 3812 esc_html( $data['threshold'] ),
3459 3813 esc_html( $data['cores'] )
3460 3814 );
@@ -3472,9 +3826,10 @@
3472 3826 if (!get_transient('metasync_llms_conflict')) {
3473 3827 return;
3474 3828 }
3475 3829 echo '<div class="notice notice-info is-dismissible"><p>';
3476 - echo esc_html__('Note: Another SEO plugin (Yoast, Rank Math, or AIOSEO) may also be generating /llms.txt. MetaSync\'s version takes priority when enabled.', 'metasync');
3830 + /* translators: %s: effective plugin name (whitelabel-aware). */
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()));
3477 3832 echo '</p></div>';
3478 3833 }
3479 3834
3480 3835 /**
@@ -3543,8 +3898,17 @@
3543 3898 public function ajax_restore_robots_backup()
3544 3899 {
3545 3900 Metasync_Admin_Ajax::instance()->ajax_restore_robots_backup();
3546 3901 }
3902 +
3903 + /**
3904 + * AJAX handler to fetch a paginated page of robots.txt backups
3905 + */
3906 + public function ajax_get_robots_backups()
3907 + {
3908 + Metasync_Admin_Ajax::instance()->ajax_get_robots_backups();
3909 + }
3910 +
3547 3911 public function ajax_create_redirect_from_404()
3548 3912 {
3549 3913 Metasync_Admin_Ajax::instance()->ajax_create_redirect_from_404();
3550 3914 }
@@ -3656,17 +4020,17 @@
3656 4020 return;
3657 4021 }
3658 4022
3659 4023 // Get pagination parameters
3660 - $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
3661 - $per_page = 10;
4024 + $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
4025 + $per_page = Metasync_Per_Page_Helper::resolve('sync_log', 10);
3662 4026 $offset = ($page - 1) * $per_page;
3663 4027
3664 4028 // Get filters
3665 4029 $filters = [
3666 4030 // UI exposes date_range and status only. We compute date_from/date_to based on date_range
3667 - 'date_range' => isset($_GET['date_range']) ? sanitize_text_field(wp_unslash($_GET['date_range'])) : '',
3668 - '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
3669 4033 ];
3670 4034
3671 4035 // Map date_range to concrete date_from/date_to for DB queries
3672 4036 $date_range = $filters['date_range'];
@@ -3675,28 +4039,28 @@
3675 4039 $date_to = '';
3676 4040
3677 4041 if (!empty($date_range)) {
3678 4042 // End boundary is now by default
3679 - $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);
3680 4044
3681 4045 if ($date_range === 'today') {
3682 4046 $start_ts = strtotime('today', $wp_now_ts);
3683 - $date_from = date('Y-m-d H:i:s', $start_ts);
4047 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3684 4048 } elseif ($date_range === 'yesterday') {
3685 4049 $start_ts = strtotime('yesterday', $wp_now_ts);
3686 4050 $end_ts = strtotime('today', $wp_now_ts) - 1; // end of yesterday
3687 - $date_from = date('Y-m-d H:i:s', $start_ts);
3688 - $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);
3689 4053 } elseif ($date_range === 'this_week') {
3690 4054 $start_of_week = (int) get_option('start_of_week', 1); // 0=Sun, 1=Mon
3691 - $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
3692 4056 // Convert start_of_week to PHP's 0..6 where 0=Sunday
3693 4057 $delta_days = ($day_of_week - $start_of_week + 7) % 7;
3694 4058 $start_ts = strtotime('-' . $delta_days . ' days', strtotime('today', $wp_now_ts));
3695 - $date_from = date('Y-m-d H:i:s', $start_ts);
4059 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3696 4060 } elseif ($date_range === 'this_month') {
3697 - $start_ts = strtotime(date('Y-m-01 00:00:00', $wp_now_ts));
3698 - $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);
3699 4063 } elseif ($date_range === 'all') {
3700 4064 // no bounds
3701 4065 }
3702 4066 }
@@ -3723,12 +4087,24 @@
3723 4087 ?>
3724 4088 <div class="dashboard-card">
3725 4089 <div class="sync-log-header">
3726 4090 <div class="sync-log-title-section">
3727 - <h2>Changes Log</h2>
3728 - <p style="color: var(--dashboard-text-secondary); margin-bottom: 0;">
3729 - Recent content synchronizations from external tools.
3730 - <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>
3731 4107 </p>
3732 4108 </div>
3733 4109
3734 4110 <!-- Filters + Clear Log button - Right aligned -->
@@ -3737,10 +4113,10 @@
3737 4113 style="background:#dc3545;color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:13px;"
3738 4114 data-nonce="<?php echo esc_attr(wp_create_nonce('metasync_clear_sync_log')); ?>">
3739 4115 🗑 Clear Log
3740 4116 </button>
3741 - <form method="get" class="sync-filters-form" onchange="this.submit()">
3742 - <input type="hidden" name="page" value="<?php echo esc_attr($_GET['page']); ?>">
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;">
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 ?>">
3743 4119
3744 4120 <select name="date_range" class="sync-filter-select">
3745 4121 <option value="all" <?php selected($filters['date_range'] ?? 'all', 'all'); ?>> All Time</option>
3746 4122 <option value="today" <?php selected($filters['date_range'] ?? '', 'today'); ?>>Today</option>
@@ -3762,9 +4138,9 @@
3762 4138 <div class="sync-log-list">
3763 4139 <?php if (empty($sync_records)): ?>
3764 4140 <div class="sync-log-empty">
3765 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>
3766 - <h3>No sync records found</h3>
4142 + <h2>No sync records found</h2>
3767 4143 <p>Sync records will appear here when content/pages receive new updates.</p>
3768 4144 </div>
3769 4145 <?php else: ?>
3770 4146 <?php foreach ($sync_records as $record): ?>
@@ -3818,30 +4194,32 @@
3818 4194 <?php endif; ?>
3819 4195 </div>
3820 4196
3821 4197 <!-- Pagination -->
3822 - <?php if ($total_pages > 1): ?>
3823 - <div class="sync-log-pagination">
3824 - <div class="sync-log-pagination-info">
3825 - Total records: <?php echo intval( $total_records ); ?> | Showing <?php echo intval( $offset ) + 1; ?>-<?php echo intval( min($offset + $per_page, $total_records) ); ?>
3826 - </div>
4198 + <div class="sync-log-pagination">
4199 + <div class="sync-log-pagination-info">
4200 + Total records: <?php echo intval( $total_records ); ?><?php if ($total_records > 0): ?> | Showing <?php echo intval( $offset ) + 1; ?>-<?php echo intval( min($offset + $per_page, $total_records) ); ?><?php endif; ?>
4201 + <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper returns a complete escaped selector element.
4202 + echo Metasync_Per_Page_Helper::render_selector('sync_log', absint($per_page)); ?>
4203 + </div>
3827 4204
4205 + <?php if ($total_pages > 1): ?>
3828 4206 <div class="sync-log-pagination-controls">
3829 4207 <?php if ($page > 1): ?>
3830 - <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>
3831 4209 <?php endif; ?>
3832 4210
3833 4211 <?php for ($i = max(1, $page - 2); $i <= min($total_pages, $page + 2); $i++): ?>
3834 - <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) ); ?>"
3835 4213 class="sync-pagination-btn <?php echo $i === $page ? 'active' : ''; ?>"><?php echo intval( $i ); ?></a>
3836 4214 <?php endfor; ?>
3837 4215
3838 4216 <?php if ($page < $total_pages): ?>
3839 - <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>
3840 4218 <?php endif; ?>
3841 4219 </div>
3842 - </div>
3843 - <?php endif; ?>
4220 + <?php endif; ?>
4221 + </div>
3844 4222 </div>
3845 4223 <?php $this->render_layout_close(); ?>
3846 4224
3847 4225 <script>
@@ -3922,8 +4300,19 @@
3922 4300 if (!empty($value)) {
3923 4301 $query_parts[] = $key . '=' . urlencode($value);
3924 4302 }
3925 4303 }
4304 +
4305 + // Preserve the user-selected results-per-page value across page
4306 + // navigation so a non-default page size survives clicking a page link.
4307 + $per_page_key = Metasync_Per_Page_Helper::request_key('sync_log');
4308 + if (isset($_GET[$per_page_key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4309 + $per_page = (int) $_GET[$per_page_key]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4310 + if (in_array($per_page, Metasync_Per_Page_Helper::allowed_values(), true)) {
4311 + $query_parts[] = $per_page_key . '=' . $per_page;
4312 + }
4313 + }
4314 +
3926 4315 return !empty($query_parts) ? '&' . implode('&', $query_parts) : '';
3927 4316 }
3928 4317
3929 4318 /**
@@ -4097,18 +4486,8 @@
4097 4486 {
4098 4487 Metasync_Connect_Manager::instance()->handle_session_management_early();
4099 4488 }
4100 4489
4101 - /**
4102 - * @deprecated 2.5.12 Use Metasync_Auth_Manager instead of sessions for authentication
4103 - */
4104 - private function safe_session_start() {
4105 - // This method is deprecated and no longer used
4106 - // Authentication now uses Metasync_Auth_Manager with WordPress transients and user meta
4107 - _deprecated_function(__METHOD__, '2.5.12', 'Metasync_Auth_Manager');
4108 - return Metasync_Session_Helper::safe_start();
4109 - }
4110 -
4111 4490 private function handle_whitelabel_session_logic()
4112 4491 {
4113 4492 Metasync_Connect_Manager::instance()->handle_whitelabel_session_logic();
4114 4493 }
@@ -4154,8 +4533,9 @@
4154 4533 $stats = Metasync_CPU_Monitor::get_stats();
4155 4534 $per_core_threshold = Metasync_CPU_Monitor::get_per_core_threshold();
4156 4535 $cores = Metasync_CPU_Monitor::get_cpu_core_count();
4157 4536 $effective_threshold = Metasync_CPU_Monitor::get_effective_threshold();
4537 + $detection_reliable = Metasync_CPU_Monitor::is_core_detection_reliable();
4158 4538 ?>
4159 4539 <div style="background: var(--dashboard-card-bg); padding: 20px; border-radius: 8px;">
4160 4540 <!-- CPU Cores Detected -->
4161 4541 <div style="margin-bottom: 24px;">
@@ -4162,12 +4542,20 @@
4162 4542 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4163 4543 CPU Cores Detected
4164 4544 </label>
4165 4545 <div style="padding: 10px 12px; background: var(--dashboard-input-bg); border: 1px solid var(--dashboard-border); border-radius: 6px; color: var(--dashboard-text-secondary);">
4166 - <strong><?php echo intval($cores); ?></strong> core<?php echo $cores !== 1 ? 's' : ''; ?>
4546 + <?php if ($detection_reliable) : ?>
4547 + <strong><?php echo intval($cores); ?></strong> core<?php echo $cores !== 1 ? 's' : ''; ?>
4548 + <?php else : ?>
4549 + <strong>Not detected</strong>
4550 + <?php endif; ?>
4167 4551 </div>
4168 4552 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
4169 - Automatically detected on this system.
4553 + <?php if ($detection_reliable) : ?>
4554 + Automatically detected on this system.
4555 + <?php else : ?>
4556 + Core detection is not available on this hosting environment.
4557 + <?php endif; ?>
4170 4558 </p>
4171 4559 </div>
4172 4560
4173 4561 <!-- Per-Core Load Threshold -->
@@ -4173,8 +4561,9 @@
4173 4561 <!-- Per-Core Load Threshold -->
4174 4562 <div style="margin-bottom: 24px;">
4175 4563 <label for="cpu_load_per_core_threshold" style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4176 4564 Per-Core Load Threshold
4565 + <?php Metasync::render_tooltip_icon('cpu_per_core_load_threshold', 'How busy each CPU core can get before the plugin pauses background SEO syncing. Lower = more cautious. Most sites leave this at 2.0.'); ?>
4177 4566 </label>
4178 4567 <input type="number"
4179 4568 id="cpu_load_per_core_threshold"
4180 4569 name="metasync_options[performance][cpu_load_per_core_threshold]"
@@ -4194,9 +4583,9 @@
4194 4583 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4195 4584 Effective Threshold
4196 4585 </label>
4197 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);">
4198 - <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>
4199 4588 </div>
4200 4589 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
4201 4590 Calculated as: cores × per-core threshold
4202 4591 </p>
@@ -4214,13 +4603,13 @@
4214 4603 <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo intval($stats['deferrals']); ?></div>
4215 4604 </div>
4216 4605 <div>
4217 4606 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Max Load Observed</div>
4218 - <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>
4219 4608 </div>
4220 4609 <div>
4221 4610 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Average Load</div>
4222 - <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>
4223 4612 </div>
4224 4613 </div>
4225 4614 </div>
4226 4615
@@ -4726,88 +5115,8 @@
4726 5115
4727 5116 /**
4728 5117 * Get the settings option array and print one of its values
4729 5118 */
4730 - public function no_index_posts_callback()
4731 - {
4732 - Metasync_Settings_Fields::instance()->no_index_posts_callback();
4733 - }
4734 -
4735 - /**
4736 - * Get the settings option array and print one of its values
4737 - */
4738 - public function no_follow_links_callback()
4739 - {
4740 - Metasync_Settings_Fields::instance()->no_follow_links_callback();
4741 - }
4742 -
4743 - /**
4744 - * Get the settings option array and print one of its values
4745 - */
4746 - public function open_external_links_callback()
4747 - {
4748 - Metasync_Settings_Fields::instance()->open_external_links_callback();
4749 - }
4750 -
4751 - /**
4752 - * Get the settings option array and print one of its values
4753 - */
4754 - public function add_alt_image_tags_callback()
4755 - {
4756 - Metasync_Settings_Fields::instance()->add_alt_image_tags_callback();
4757 - }
4758 -
4759 - /**
4760 - * Get the settings option array and print one of its values
4761 - */
4762 - public function add_title_image_tags_callback()
4763 - {
4764 - Metasync_Settings_Fields::instance()->add_title_image_tags_callback();
4765 - }
4766 -
4767 - /**
4768 - * Get the settings option array and print one of its values
4769 - */
4770 - public function site_type_callback()
4771 - {
4772 - Metasync_Settings_Fields::instance()->site_type_callback();
4773 - }
4774 -
4775 - /**
4776 - * Get the settings option array and print one of its values
4777 - */
4778 - public function site_business_type_callback()
4779 - {
4780 - Metasync_Settings_Fields::instance()->site_business_type_callback();
4781 - }
4782 -
4783 - /**
4784 - * Get the settings option array and print one of its values
4785 - */
4786 - public function site_company_name_callback()
4787 - {
4788 - Metasync_Settings_Fields::instance()->site_company_name_callback();
4789 - }
4790 -
4791 - /**
4792 - * Get the settings option array and print one of its values
4793 - */
4794 - public function site_google_logo_callback()
4795 - {
4796 - Metasync_Settings_Fields::instance()->site_google_logo_callback();
4797 - }
4798 -
4799 - /**
4800 - * Get the settings option array and print one of its values
4801 - */
4802 - public function site_social_share_image_callback()
4803 - {
4804 - Metasync_Settings_Fields::instance()->site_social_share_image_callback();
4805 - }
4806 -
4807 - /**
4808 - * Get the settings option array and print one of its values
4809 - */
4810 5119 public function common_robot_meta_tags_callback()
4811 5120 {
4812 5121 Metasync_Settings_Fields::instance()->common_robot_meta_tags_callback();
4813 5122 }
@@ -5005,8 +5314,14 @@
5005 5314 * Show a one-time admin notice when a page builder is detected but the
5006 5315 * "Default Page Builder" setting has never been explicitly saved.
5007 5316 */
5008 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 +
5009 5324 $configured = Metasync::get_option('general')['default_page_builder'] ?? '';
5010 5325
5011 5326 // Setting already saved — nothing to warn about
5012 5327 if (!empty($configured)) {
@@ -5019,9 +5334,9 @@
5019 5334 return;
5020 5335 }
5021 5336
5022 5337 // Handle dismiss action
5023 - 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')) {
5024 5339 update_user_meta(get_current_user_id(), 'metasync_builder_notice_dismissed', '1');
5025 5340 return;
5026 5341 }
5027 5342
@@ -5116,9 +5431,10 @@
5116 5431 * Saves the CPU load threshold and returns statistics
5117 5432 */
5118 5433 public function ajax_save_performance_settings() {
5119 5434 # Check nonce for security and return early if invalid
5120 - 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')) {
5121 5437 wp_send_json_error(array('message' => 'Invalid nonce'));
5122 5438 return;
5123 5439 }
5124 5440
@@ -5186,9 +5502,9 @@
5186 5502 if (!wp_next_scheduled('metasync_cleanup_transients')) {
5187 5503 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_cleanup_transients');
5188 5504
5189 5505 if (!$scheduled) {
5190 - 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
5191 5507 }
5192 5508 }
5193 5509 }
5194 5510
@@ -5199,9 +5515,9 @@
5199 5515 {
5200 5516 $timestamp = wp_next_scheduled('metasync_cleanup_transients');
5201 5517 if ($timestamp) {
5202 5518 wp_unschedule_event($timestamp, 'metasync_cleanup_transients');
5203 - 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
5204 5520 }
5205 5521 }
5206 5522
5207 5523 /**
@@ -5223,12 +5539,10 @@
5223 5539 {
5224 5540 if (!wp_next_scheduled('metasync_hidden_post_check')) {
5225 5541 $scheduled = wp_schedule_event(time(), 'metasync_weekly', 'metasync_hidden_post_check');
5226 5542
5227 - if ($scheduled) {
5228 - error_log('MetaSync: Hidden post manager cron job scheduled successfully (runs every 7 days)');
5229 - } else {
5230 - 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
5231 5545 }
5232 5546 }
5233 5547 }
5234 5548
@@ -5239,12 +5553,10 @@
5239 5553 public function maybe_schedule_otto_recheck_404_cron()
5240 5554 {
5241 5555 if (!wp_next_scheduled('metasync_otto_recheck_404_exclusions')) {
5242 5556 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_otto_recheck_404_exclusions');
5243 - if ($scheduled) {
5244 - error_log('MetaSync: OTTO 404 recheck cron job scheduled successfully (runs daily)');
5245 - } else {
5246 - 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
5247 5559 }
5248 5560 }
5249 5561 }
5250 5562
@@ -5317,13 +5629,13 @@
5317 5629
5318 5630 try {
5319 5631 // 1. Post revisions
5320 5632 if (!empty($settings['clean_post_revisions'])) {
5321 - $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
5322 5634 "DELETE FROM {$wpdb->posts} WHERE post_type = 'revision'"
5323 5635 );
5324 5636 // Remove postmeta left behind by deleted revisions
5325 - $wpdb->query(
5637 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5326 5638 "DELETE pm FROM {$wpdb->postmeta} pm
5327 5639 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5328 5640 WHERE p.ID IS NULL"
5329 5641 );
@@ -5331,20 +5643,19 @@
5331 5643
5332 5644 // 2. Trashed posts + their postmeta
5333 5645 if (!empty($settings['clean_trashed_posts'])) {
5334 5646 // Collect IDs first to cleanly remove postmeta
5335 - $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
5336 5648 "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'trash'"
5337 5649 );
5338 5650 if (!empty($trashed_ids)) {
5339 - $placeholders = implode(',', array_fill(0, count($trashed_ids), '%d'));
5340 - $wpdb->query(
5651 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5341 5652 $wpdb->prepare(
5342 - "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')) . ')',
5343 5654 $trashed_ids
5344 5655 )
5345 5656 );
5346 - $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
5347 5658 "DELETE FROM {$wpdb->posts} WHERE post_status = 'trash'"
5348 5659 );
5349 5660 } else {
5350 5661 $stats['trashed_posts'] = 0;
@@ -5352,9 +5663,9 @@
5352 5663 }
5353 5664
5354 5665 // 3. Trashed comments
5355 5666 if (!empty($settings['clean_trashed_comments'])) {
5356 - $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
5357 5668 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'trash'"
5358 5669 );
5359 5670 }
5360 5671
@@ -5359,9 +5670,9 @@
5359 5670 }
5360 5671
5361 5672 // 4. Spam comments
5362 5673 if (!empty($settings['clean_spam_comments'])) {
5363 - $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
5364 5675 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'spam'"
5365 5676 );
5366 5677 }
5367 5678
@@ -5367,15 +5678,15 @@
5367 5678
5368 5679 // 5. Expired transients — direct SQL, no cache flush
5369 5680 if (!empty($settings['clean_expired_transients'])) {
5370 5681 // Delete timeout rows that have already expired
5371 - $wpdb->query(
5682 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5372 5683 "DELETE FROM {$wpdb->options}
5373 5684 WHERE option_name LIKE '\_transient\_timeout\_%'
5374 5685 AND option_value + 0 < UNIX_TIMESTAMP()"
5375 5686 );
5376 5687 // Delete value rows whose timeout row no longer exists
5377 - $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
5378 5689 "DELETE o FROM {$wpdb->options} o
5379 5690 LEFT JOIN {$wpdb->options} t
5380 5691 ON t.option_name = CONCAT('_transient_timeout_', SUBSTRING(o.option_name, 12))
5381 5692 WHERE o.option_name LIKE '\_transient\_%'
@@ -5385,9 +5696,9 @@
5385 5696 }
5386 5697
5387 5698 // 6. Orphaned postmeta (post_id references a post that no longer exists)
5388 5699 if (!empty($settings['clean_orphaned_postmeta'])) {
5389 - $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
5390 5701 "DELETE pm FROM {$wpdb->postmeta} pm
5391 5702 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5392 5703 WHERE p.ID IS NULL"
5393 5704 );
@@ -5399,12 +5710,10 @@
5399 5710 $settings['last_run_at'] = time();
5400 5711 $settings['last_run_stats'] = $stats;
5401 5712 update_option('metasync_db_cleanup_settings', $settings);
5402 5713
5403 - error_log('MetaSync: DB cleanup completed — ' . json_encode($stats));
5404 -
5405 5714 } catch (Exception $e) {
5406 - 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
5407 5716 }
5408 5717 }
5409 5718
5410 5719 /**
@@ -5621,10 +5930,11 @@
5621 5930 /**
5622 5931 * AJAX: Save DB cleanup settings and reschedule cron accordingly.
5623 5932 */
5624 5933 public function ajax_save_db_cleanup_settings() {
5934 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5625 5935 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5626 - !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')) {
5627 5937 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
5628 5938 return;
5629 5939 }
5630 5940
@@ -5681,10 +5991,11 @@
5681 5991 * AJAX: Manually trigger the DB cleanup and return stats for the UI.
5682 5992 * Persists current form state first so unsaved checkbox changes are respected.
5683 5993 */
5684 5994 public function ajax_run_db_cleanup() {
5995 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5685 5996 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5686 - !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')) {
5687 5998 wp_send_json_error(array('message' => 'Invalid security token.'));
5688 5999 return;
5689 6000 }
5690 6001
@@ -5760,35 +6071,8 @@
5760 6071 ));
5761 6072 }
5762 6073
5763 6074 /**
5764 - * Control plugin auto-updates based on user setting
5765 - *
5766 - * @param bool $update Whether to update
5767 - * @param object $item Update offer
5768 - * @return bool Whether to allow auto-update
5769 - */
5770 - public function control_plugin_auto_updates($update, $item)
5771 - {
5772 - # Check if the item object has the slug property
5773 - if (!isset($item->slug)) {
5774 - return $update;
5775 - }
5776 -
5777 - // Check if this is our plugin
5778 - if ($item->slug === 'metasync') {
5779 - $general_settings = Metasync::get_option('general') ?? [];
5780 - $enable_auto_updates = $general_settings['enable_auto_updates'] ?? false;
5781 -
5782 - // Return the user's preference (true = allow auto-updates, false = prevent)
5783 - return $enable_auto_updates === 'true' || $enable_auto_updates === true;
5784 - }
5785 -
5786 - // For other plugins, don't interfere with their auto-update settings
5787 - return $update;
5788 - }
5789 -
5790 - /**
5791 6075 * AJAX handler to add excluded URL for OTTO
5792 6076 */
5793 6077 public function ajax_otto_add_excluded_url()
5794 6078 {
@@ -5934,8 +6218,9 @@
5934 6218 // If page has raw HTML or custom CSS from conversion, show badge
5935 6219 if ($has_raw_html || !empty($has_custom_css)) {
5936 6220 $label = $this->get_html_source_label();
5937 6221 $tooltip = sprintf(
6222 + /* translators: %s: HTML source label. */
5938 6223 __('This page was created using %s HTML-to-Builder converter', 'metasync'),
5939 6224 $label
5940 6225 );
5941 6226
@@ -5974,8 +6259,16 @@
5974 6259 if (!$post || !in_array($post->post_type, array('post', 'page'))) {
5975 6260 return;
5976 6261 }
5977 6262
6263 + // Don't show the "HTML-to-Builder converter" banner on LPS-synced / custom-HTML
6264 + // pages: those are raw-HTML store-and-serve pages, NOT actually converted to a
6265 + // page builder, so the banner mislabels them. It still shows for pages genuinely
6266 + // produced by the converter.
6267 + if (function_exists('metasync_is_custom_or_lps_page') && metasync_is_custom_or_lps_page($post->ID)) {
6268 + return;
6269 + }
6270 +
5978 6271 $has_raw_html = get_post_meta($post->ID, '_metasync_raw_html_enabled', true);
5979 6272 $has_custom_css = get_post_meta($post->ID, '_metasync_custom_css', true);
5980 6273
5981 6274 if ($has_raw_html || !empty($has_custom_css)) {
@@ -5980,8 +6273,9 @@
5980 6273
5981 6274 if ($has_raw_html || !empty($has_custom_css)) {
5982 6275 $label = $this->get_html_source_label();
5983 6276 $message = sprintf(
6277 + /* translators: %s: HTML source label. */
5984 6278 __('This page was created using %s HTML-to-Builder converter. The design is preserved with custom CSS and inline styles.', 'metasync'),
5985 6279 '<strong>' . esc_html($label) . '</strong>'
5986 6280 );
5987 6281
@@ -5995,9 +6289,9 @@
5995 6289 <p class="metasync-editor-message">%s</p>
5996 6290 </div>
5997 6291 </div>',
5998 6292 esc_html($label),
5999 - $message
6293 + wp_kses($message, array('strong' => array()))
6000 6294 );
6001 6295 }
6002 6296 }
6003 6297
@@ -6020,9 +6314,9 @@
6020 6314 ?>
6021 6315 <fieldset class="inline-edit-col-left metasync-quick-edit-source">
6022 6316 <div class="inline-edit-col">
6023 6317 <label>
6024 - <span class="title"><?php _e('Source', 'metasync'); ?></span>
6318 + <span class="title"><?php esc_html_e('Source', 'metasync'); ?></span>
6025 6319 <span class="metasync-quick-edit-badge-container"></span>
6026 6320 </label>
6027 6321 </div>
6028 6322 </fieldset>
@@ -6034,8 +6328,9 @@
6034 6328 */
6035 6329 public function add_html_pages_dashboard_widget()
6036 6330 {
6037 6331 $label = $this->get_html_source_label();
6332 + /* translators: %s: HTML source label. */
6038 6333 $widget_title = sprintf(__('%s Pages', 'metasync'), $label);
6039 6334
6040 6335 wp_add_dashboard_widget(
6041 6336 'metasync_html_pages_widget',
@@ -6188,8 +6483,18 @@
6188 6483 if (!isset($_POST['submit'])) {
6189 6484 return;
6190 6485 }
6191 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 +
6192 6497 // Save post types for Google Instant Indexing auto-submit
6193 6498 if (isset($_POST['metasync_post_types'])) {
6194 6499 $post_data = metasync_sanitize_input_array($_POST);
6195 6500 $post_types = is_array($post_data['metasync_post_types']) ? array_map('sanitize_title', $post_data['metasync_post_types']) : [];
@@ -6273,12 +6578,24 @@
6273 6578 if (!empty($seo_controls['enable_googleinstantindex']) && $seo_controls['enable_googleinstantindex'] === 'true') {
6274 6579 $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
6275 6580 $post_types = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
6276 6581
6277 - if (in_array($post->post_type, $post_types) && function_exists('google_index_direct')) {
6278 - $service_info = google_index_direct()->get_service_account_info();
6279 - if (!isset($service_info['error'])) {
6280 - 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 + }
6281 6598 }
6282 6599 }
6283 6600 }
6284 6601