PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | admin/class-metasync-admin.php +682 -394 2.6.15 → 2.7.2 View file →
@@ -33,9 +33,8 @@
33 33 const SECTION_METASYNC = "metasync_settings";
34 34 const SECTION_SEARCHENGINE = "searchengine_settings";
35 35 const SECTION_LOCALSEO = "local_seo";
36 36 const SECTION_CODESNIPPETS = "code_snippets";
37 - const SECTION_OPTIMAL_SETTINGS = "optimal_settings";
38 37 const SECTION_SITE_SETTINGS = "site_settings";
39 38 const SECTION_COMMON_SETTINGS = "common_settings";
40 39 const SECTION_COMMON_META_SETTINGS = "common_meta_settings";
41 40 const SECTION_SOCIAL_META = "social_meta";
@@ -42,9 +41,8 @@
42 41 const SECTION_SEO_CONTROLS = "seo_controls";
43 42 const SECTION_SEO_CONTROLS_ADVANCED = "seo_controls_advanced";
44 43 const SECTION_SEO_CONTROLS_INSTANT_INDEX = "seo_controls_instant_index";
45 44 const SECTION_PLUGIN_VISIBILITY = "plugin_visibility_settings";
46 - const SECTION_BREADCRUMBS = "breadcrumbs_settings";
47 45 const SECTION_LLMS_TXT = "llms_txt_settings";
48 46
49 47 /**
50 48 * The ID of this plugin.
@@ -200,9 +198,9 @@
200 198 // Set menu title using the effective title (includes whitelabel company name if available)
201 199 $this->menu_title = $this->get_effective_menu_title();
202 200 $raw_slug = isset($data['white_label_plugin_menu_slug']) ? $data['white_label_plugin_menu_slug'] : '';
203 201 $clean_slug = sanitize_title($raw_slug);
204 - # Self-heal a legacy URL-shaped slug (WP-413): persist the sanitized value so every
202 + # Self-heal a legacy URL-shaped slug: persist the sanitized value so every
205 203 # reader that builds admin links from the stored option gets a valid WP menu slug.
206 204 if ($raw_slug !== '' && $clean_slug !== $raw_slug) {
207 205 $options = Metasync::get_option();
208 206 $options['general']['white_label_plugin_menu_slug'] = $clean_slug;
@@ -214,9 +212,8 @@
214 212 add_action('admin_menu', array($this, 'add_import_external_data_page'));
215 213 add_action('admin_init', array($this, 'settings_page_init'));
216 214 add_filter('all_plugins', array($this,'metasync_plugin_white_label'));
217 215 add_filter( 'plugin_row_meta',array($this,'metasync_view_detials_url'),10,3);
218 - add_filter('site_transient_update_plugins', array($this, 'inject_whitelabel_icon_into_update_transient'));
219 216
220 217 // Display transient error/success messages for redirections
221 218 add_action('admin_notices', array($this, 'display_redirection_messages'));
222 219
@@ -266,8 +263,15 @@
266 263
267 264 add_action('admin_init', array($this, 'initialize_cookie'));
268 265 add_action('admin_init', array($this, 'maybe_redirect_to_wizard'));
269 266
267 + // The redirect must run on admin_menu, not admin_init: in current
268 + // WordPress the unregistered-page access check in wp-admin/includes/
269 + // menu.php fires from wp-admin/menu.php (required at admin.php:163),
270 + // which is BEFORE admin_init (admin.php:180). Hooked on admin_init the
271 + // redirect never ran — WP died with a 403 first for the removed slug.
272 + add_action('admin_menu', array($this, 'redirect_legacy_instant_indexing_page'));
273 +
270 274 // Add admin_post hooks for form submissions (WordPress standard way - no output buffering needed)
271 275 add_action('admin_post_metasync_clear_all_cache_plugins', array($this, 'handle_clear_all_cache_plugins'));
272 276 add_action('admin_post_metasync_clear_otto_cache_all', array($this, 'handle_clear_otto_cache_all'));
273 277 add_action('admin_post_metasync_clear_otto_cache_url', array($this, 'handle_clear_otto_cache_url'));
@@ -284,8 +288,11 @@
284 288
285 289 // Add AJAX for saving execution settings
286 290 add_action( 'wp_ajax_metasync_save_execution_settings', array($this, 'ajax_save_execution_settings') );
287 291
292 + // Add AJAX for saving the Headless Mode demo controls on the Advanced tab
293 + add_action( 'wp_ajax_metasync_save_headless_settings', array(Metasync_Settings_Registration::instance(), 'ajax_save_headless_settings') );
294 +
288 295 // Add AJAX for saving hosting cache settings
289 296 add_action( 'wp_ajax_metasync_save_hosting_cache_settings', array($this, 'ajax_save_hosting_cache_settings') );
290 297
291 298 // Add AJAX for saving OTTO Cache TTL
@@ -315,8 +322,9 @@
315 322 add_action('wp_ajax_metasync_get_default_robots', array($this, 'ajax_get_default_robots'));
316 323 add_action('wp_ajax_metasync_preview_robots_backup', array($this, 'ajax_preview_robots_backup'));
317 324 add_action('wp_ajax_metasync_delete_robots_backup', array($this, 'ajax_delete_robots_backup'));
318 325 add_action('wp_ajax_metasync_restore_robots_backup', array($this, 'ajax_restore_robots_backup'));
326 + add_action('wp_ajax_metasync_get_robots_backups', array($this, 'ajax_get_robots_backups'));
319 327
320 328 // Add AJAX handlers for host blocking test
321 329 add_action('wp_ajax_metasync_test_host_blocking_get', array($this, 'ajax_test_host_blocking_get'));
322 330 add_action('wp_ajax_metasync_test_host_blocking_post', array($this, 'ajax_test_host_blocking_post'));
@@ -370,10 +378,18 @@
370 378 add_action('wp_ajax_metasync_batch_progress', array($this, 'ajax_batch_progress'));
371 379 add_action('wp_ajax_metasync_bulk_optimize_selected', array($this, 'ajax_bulk_optimize_selected'));
372 380 add_action('wp_ajax_metasync_bulk_unoptimize_selected', array($this, 'ajax_bulk_unoptimize_selected'));
373 381 add_action('wp_ajax_metasync_process_batch_tick', array($this, 'ajax_process_batch_tick'));
382 + add_action('wp_ajax_metasync_delete_orphaned_image', array($this, 'ajax_delete_orphaned_image'));
374 383 add_action('metasync_media_batch_optimize_cron', array($this, 'handle_media_batch_cron'));
375 384
385 + # SEO Restore (Rollback) AJAX & Cron handlers
386 + add_action('wp_ajax_metasync_seo_restore_start', array($this, 'ajax_seo_restore_start'));
387 + add_action('wp_ajax_metasync_seo_restore_cancel', array($this, 'ajax_seo_restore_cancel'));
388 + add_action('wp_ajax_metasync_seo_restore_progress', array($this, 'ajax_seo_restore_progress'));
389 + add_action('wp_ajax_metasync_seo_restore_process_tick', array($this, 'ajax_seo_restore_process_tick'));
390 + add_action('metasync_seo_restore_cron', array($this, 'handle_seo_restore_cron'));
391 +
376 392 # Add AJAX handlers for Google Instant Indexing
377 393 add_action('wp_ajax_metasync_send_giapi', array($this, 'ajax_send_giapi'));
378 394
379 395 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
@@ -378,11 +394,8 @@
378 394
379 395 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
380 396 add_action('wp_ajax_metasync_send_bing_indexnow', array($this, 'ajax_send_bing_indexnow'));
381 397
382 - # Add hooks for instant indexing settings saves
383 - add_action('admin_init', array($this, 'save_instant_indexing_settings'));
384 -
385 398 # Add hooks for instant indexing post actions
386 399 add_filter('post_row_actions', array($this, 'add_instant_indexing_post_actions'), 10, 2);
387 400 add_filter('page_row_actions', array($this, 'add_instant_indexing_post_actions'), 10, 2);
388 401
@@ -466,9 +479,9 @@
466 479
467 480 $data= Metasync::get_option('general');
468 481
469 482 # Get white label menu slug
470 - # Sanitize so a URL-shaped legacy value still yields a valid WP menu slug (WP-413)
483 + # Sanitize so a URL-shaped legacy value still yields a valid WP menu slug
471 484 $menu_slug = empty($data['white_label_plugin_menu_slug']) ? self::$page_slug : sanitize_title($data['white_label_plugin_menu_slug']);
472 485 $menu_slug = $menu_slug === '' ? self::$page_slug : $menu_slug;
473 486
474 487 ?>
@@ -487,23 +500,129 @@
487 500 <?php
488 501 }
489 502
490 503 public function metasync_fouc_prevention_style() {
491 - if ( ! isset( $_GET['page'] ) || strpos( $_GET['page'], self::$page_slug ) !== 0 ) {
504 + if ( ! isset( $_GET['page'] ) || strpos( sanitize_key(wp_unslash($_GET['page'])), self::$page_slug ) !== 0 ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check for an inline style
492 505 return;
493 506 }
494 507 ?>
495 - <style>.metasync-dashboard-wrap { opacity: 0; transition: opacity 0.15s ease-in; }</style>
508 + <style>
509 + .metasync-dashboard-wrap { opacity: 0; transition: opacity 0.15s ease-in; }
510 + /*
511 + * WP prints admin notices at the top of #wpbody-content, then core's
512 + * common.js relocates them next to .wp-header-end inside our wrap on
513 + * DOM-ready. Keep them out of the flow until that move so they don't
514 + * flash above the dashboard header and shove the page down.
515 + * Once relocated they're no longer a direct child here, so this rule
516 + * stops matching and they fade in with the wrap. We mirror core's
517 + * relocation set exactly (.notice/.updated/.error, minus .inline and
518 + * .below-h2); .update-nag is left alone since core never moves it.
519 + */
520 + #wpbody-content > .notice:not(.inline):not(.below-h2),
521 + #wpbody-content > .updated:not(.inline):not(.below-h2),
522 + #wpbody-content > .error:not(.inline):not(.below-h2) { display: none !important; }
523 + </style>
496 524 <?php
497 525 }
498 526
499 527 public function suppress_notices_on_wizard_page() {
500 - if ( isset( $_GET['page'] ) && strpos( $_GET['page'], '-setup-wizard' ) !== false ) {
528 + if ( ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
529 + return;
530 + }
531 +
532 + $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
533 +
534 + // Setup wizard: strip every notice for a fully focused, distraction-free screen.
535 + if ( strpos( $page, '-setup-wizard' ) !== false ) {
501 536 remove_all_actions( 'admin_notices' );
502 537 remove_all_actions( 'all_admin_notices' );
538 + return;
503 539 }
540 +
541 + // Other Search Atlas plugin pages: strip third-party notices but keep our own.
542 + if ( strpos( $page, self::$page_slug ) === 0 ) {
543 + $this->remove_third_party_admin_notices();
544 + }
504 545 }
505 546
547 + /**
548 + * Remove admin-notice callbacks that don't belong to this plugin.
549 + *
550 + * Third-party plugins (e.g. Rank Math, Yoast) register their banners on the
551 + * global admin_notices / all_admin_notices actions, which WordPress fires on
552 + * every admin screen — so their notices leak onto our settings pages. We walk
553 + * the registered callbacks and drop any whose code lives outside this plugin's
554 + * directory, leaving every one of our own notices intact.
555 + */
556 + private function remove_third_party_admin_notices() {
557 + global $wp_filter;
558 +
559 + foreach ( array( 'admin_notices', 'all_admin_notices' ) as $hook ) {
560 + if ( empty( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof WP_Hook ) ) {
561 + continue;
562 + }
563 +
564 + // Collect first, then remove — avoids mutating the callbacks array mid-walk.
565 + $to_remove = array();
566 + foreach ( $wp_filter[ $hook ]->callbacks as $priority => $callbacks ) {
567 + foreach ( $callbacks as $callback ) {
568 + if ( ! $this->is_own_admin_notice_callback( $callback['function'] ) ) {
569 + $to_remove[] = array( $callback['function'], $priority );
570 + }
571 + }
572 + }
573 +
574 + foreach ( $to_remove as $entry ) {
575 + remove_action( $hook, $entry[0], $entry[1] );
576 + }
577 + }
578 + }
579 +
580 + /**
581 + * Determine whether an admin-notice callback is defined by this plugin.
582 + *
583 + * Ownership is decided by where the callback's code physically lives, not by
584 + * class name — our notice owners use inconsistent prefixes (Metasync_Admin,
585 + * Google_Index_Admin, ConfigControllerMetaSync), and a white-label install can
586 + * rename the plugin folder. Resolving the declaring file and checking it sits
587 + * inside this plugin's directory is robust against all of that. If a callback
588 + * can't be introspected, we keep it rather than risk hiding a legitimate notice.
589 + *
590 + * @param string|array|Closure $function The registered callback.
591 + * @return bool True when the callback's code lives inside this plugin.
592 + */
593 + private function is_own_admin_notice_callback( $function ) {
594 + // Plugin root: this file lives in <plugin>/admin, so its parent is the root.
595 + $plugin_dir = wp_normalize_path( dirname( __DIR__ ) ) . '/';
596 +
597 + try {
598 + if ( is_array( $function ) && isset( $function[0], $function[1] ) ) {
599 + $class = is_object( $function[0] ) ? get_class( $function[0] ) : $function[0];
600 + $ref = new ReflectionMethod( $class, $function[1] );
601 + } elseif ( $function instanceof Closure ) {
602 + $ref = new ReflectionFunction( $function );
603 + } elseif ( is_string( $function ) && strpos( $function, '::' ) !== false ) {
604 + list( $class, $method ) = explode( '::', $function, 2 );
605 + $ref = new ReflectionMethod( $class, $method );
606 + } elseif ( is_string( $function ) ) {
607 + $ref = new ReflectionFunction( $function );
608 + } else {
609 + return false;
610 + }
611 + } catch ( \Throwable $e ) {
612 + // Unintrospectable callback — keep it; never hide a notice we can't classify.
613 + return true;
614 + }
615 +
616 + $file = $ref->getFileName();
617 + if ( ! $file ) {
618 + // Internal/built-in callback (no source file) — not ours.
619 + return false;
620 + }
621 +
622 + return strpos( wp_normalize_path( $file ), $plugin_dir ) === 0;
623 + }
624 +
506 625 #---------fixes issue : #95 ----------
507 626 #This function is to redirect in case client changes slug on fresh install
508 627 #It is called by the add_option hook
509 628
@@ -529,9 +648,9 @@
529 648
530 649 #Redirect url to the new slug
531 650 $redirect_url = admin_url('admin.php?page=' . $current_slug);
532 651
533 - wp_redirect($redirect_url);
652 + wp_safe_redirect($redirect_url);
534 653 exit;
535 654 }
536 655 }
537 656
@@ -583,13 +702,11 @@
583 702 * @since 1.0.0
584 703 */
585 704 public function maybe_redirect_to_wizard() {
586 705 // Only redirect if wizard should be shown
587 - if (get_option('metasync_show_wizard') && !isset($_GET['page'])) {
588 - delete_option('metasync_show_wizard');
589 -
706 + if (get_option('metasync_show_wizard') && !isset($_GET['page'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- isset() routing check for a redirect to a fixed admin URL
590 707 // Don't redirect during AJAX, cron, or bulk activation
591 - if (wp_doing_ajax() || wp_doing_cron() || isset($_GET['activate-multi'])) {
708 + if (wp_doing_ajax() || wp_doing_cron() || isset($_GET['activate-multi'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- isset() routing check for a redirect to a fixed admin URL
592 709 return;
593 710 }
594 711
595 712 // Only redirect if user has access to the plugin
@@ -596,16 +713,66 @@
596 713 if (!Metasync::current_user_has_plugin_access()) {
597 714 return;
598 715 }
599 716
717 + delete_option('metasync_show_wizard');
600 718 wp_safe_redirect(admin_url('admin.php?page=' . self::$page_slug . '-setup-wizard'));
601 719 exit;
602 720 }
603 721 }
604 722
723 + /**
724 + * Redirect the removed standalone Instant Indexing page to Indexation Control.
725 + *
726 + * The menu entry is gone, but bookmarks and external links can still target
727 + * the old URL. Resolve the current white-label slug rather than assuming
728 + * "searchatlas".
729 + *
730 + * Hooked on admin_menu (see its registration): the unregistered-page 403 in
731 + * wp-admin/includes/menu.php fires before admin_init, so any later hook
732 + * would be too late to redirect.
733 + */
734 + public function redirect_legacy_instant_indexing_page() {
735 + if (wp_doing_ajax() || wp_doing_cron() || !is_admin() || !isset($_GET['page'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
736 + return;
737 + }
738 +
739 + $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
740 + $legacy_page = self::$page_slug . '-instant-index';
741 + if ($page !== $legacy_page || !Metasync::current_user_has_plugin_access()) {
742 + return;
743 + }
744 +
745 + // Indexation Control is registered only when access control keeps it
746 + // visible. Redirecting to it blindly would dead-end on an
747 + // unregistered slug (wp_die "Invalid plugin page") for users whose
748 + // white-label config hides that page — the exact configs where the
749 + // removed standalone page was the feature's only UI. The bare plugin
750 + // slug is the floor: its top-level callback resolves to the first
751 + // reachable page, so it is always a safe target.
752 + if (Metasync_Access_Control::user_can_access('hide_indexation_control')) {
753 + $target = admin_url('admin.php?page=' . self::$page_slug . '-seo-controls');
754 + $query = [];
755 + foreach (['tab', 'subtab'] as $key) {
756 + if (isset($_GET[$key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
757 + $query[$key] = sanitize_key(wp_unslash($_GET[$key])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
758 + }
759 + }
760 + if (!empty($query)) {
761 + $target = add_query_arg($query, $target);
762 + }
763 + } else {
764 + $target = admin_url('admin.php?page=' . self::$page_slug);
765 + }
766 +
767 + wp_safe_redirect($target);
768 + exit;
769 + }
770 +
605 771 public function metasync_display_error_log() {
606 772 Metasync_Debug_Manager::instance()->metasync_display_error_log($this);
607 773 }
774 +
608 775 public function metasync_update_wp_config() {
609 776 Metasync_Debug_Manager::instance()->metasync_update_wp_config();
610 777 }
611 778
@@ -648,9 +815,9 @@
648 815 ]);
649 816 $_COOKIE['metasync_previous_slug'] = $new_slug;
650 817 // Redirect to the new slug
651 818 $redirect_url = admin_url('admin.php?page=' . $old_slug);
652 - wp_redirect($redirect_url);
819 + wp_safe_redirect($redirect_url);
653 820 exit;
654 821 }else{
655 822 self::$page_slug = Metasync::get_option('general')['white_label_plugin_menu_slug']=="" ? "searchatlas":Metasync::get_option('general')['white_label_plugin_menu_slug'];
656 823 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
@@ -656,9 +823,9 @@
656 823 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
657 824
658 825 #add redirection for when the old slug is not defined
659 826 #this fixes the redirect issue #
660 - wp_redirect($redirect_url);
827 + wp_safe_redirect($redirect_url);
661 828 exit;
662 829 }
663 830 }
664 831 public function metasync_view_detials_url( $plugin_meta, $plugin_file, $plugin_data ) {
@@ -669,9 +836,10 @@
669 836 if (strpos($meta, 'open-plugin-details-modal') !== false) {
670 837 $meta = sprintf(
671 838 '<a href="%s" class="thickbox open-plugin-details-modal" aria-label="%s" data-title="%s">%s</a>',
672 839 add_query_arg('TB_iframe', 'true', $plugin_uri),
673 - esc_attr(sprintf(esc_html__('More information about %s'), $plugin_data['Name'])),
840 + /* translators: %s: plugin name. */
841 + esc_attr(sprintf(esc_html__('More information about %s', 'metasync'), $plugin_data['Name'])),
674 842 esc_attr($plugin_data['Name']),
675 843 esc_html__('View details', 'metasync')
676 844 );
677 845 break; // Exit loop after replacing the link
@@ -719,53 +887,8 @@
719 887 return $all_plugins;
720 888 }
721 889
722 890 /**
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 891 * Register the stylesheets for the admin area.
769 892 *
770 893 * @since 1.0.0
771 894 */
@@ -770,9 +893,9 @@
770 893 * @since 1.0.0
771 894 */
772 895 public function enqueue_styles()
773 896 {
774 - $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
897 + $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
775 898
776 899 if (strpos($current_page, self::$page_slug) === 0) {
777 900 wp_enqueue_style(
778 901 $this->plugin_name,
@@ -800,10 +923,11 @@
800 923 'all'
801 924 );
802 925 }
803 926
804 - // Enqueue wizard CSS if on wizard page
805 - if (isset($_GET['page']) && strpos($_GET['page'], '-setup-wizard') !== false) {
927 + // Enqueue wizard CSS on all MetaSync pages because the reusable consent
928 + // card is also displayed inside General Settings.
929 + if (strpos($current_page, self::$page_slug) === 0 || (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-setup-wizard') !== false)) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
806 930 wp_enqueue_style(
807 931 $this->plugin_name . '-setup-wizard',
808 932 plugin_dir_url(__FILE__) . 'css/metasync-setup-wizard.css',
809 933 array($this->plugin_name . '-dashboard'),
@@ -812,9 +936,9 @@
812 936 );
813 937 }
814 938
815 939 // Enqueue SEO Health CSS if on the SEO Health page
816 - if (isset($_GET['page']) && strpos($_GET['page'], '-seo-health') !== false) {
940 + if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-seo-health') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
817 941 wp_enqueue_style(
818 942 $this->plugin_name . '-seo-health',
819 943 plugin_dir_url(__FILE__) . 'css/metasync-seo-health.css',
820 944 array($this->plugin_name . '-dashboard'),
@@ -831,9 +955,9 @@
831 955 */
832 956 public function enqueue_scripts()
833 957 {
834 958 // --- Phase 5 (#887): Extracted inline JS files ---
835 - $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
959 + $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick scripts
836 960 $plugin_root_url = plugin_dir_url(dirname(__FILE__));
837 961 $is_metasync_page = ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0);
838 962
839 963 if ($is_metasync_page) {
@@ -863,10 +987,49 @@
863 987 array('jquery'),
864 988 $this->version,
865 989 true
866 990 );
991 +
992 + // Keeps core's admin-menu pinning in step with pages whose height
993 + // changes after load. Depends on 'common' so core has bound its
994 + // own handlers before this runs.
995 + wp_enqueue_script(
996 + $this->plugin_name . '-admin-menu-height',
997 + plugin_dir_url(__FILE__) . 'js/metasync-admin-menu-height.js',
998 + array('jquery', 'common'),
999 + $this->version,
1000 + true
1001 + );
867 1002 }
868 1003
1004 + $per_page_screens = [
1005 + self::$page_slug . '-redirections',
1006 + self::$page_slug . '-404-monitor',
1007 + self::$page_slug . '-sync-log',
1008 + self::$page_slug . '-media-optimization',
1009 + ];
1010 +
1011 + if (in_array($current_page, $per_page_screens, true)) {
1012 + // Results-per-page selectors use this external handler instead of
1013 + // an inline event attribute so changing a page size always reloads
1014 + // the current admin page.
1015 + wp_enqueue_script(
1016 + $this->plugin_name . '-per-page',
1017 + plugin_dir_url(__FILE__) . 'js/metasync-per-page.js',
1018 + array(),
1019 + $this->version,
1020 + true
1021 + );
1022 + // Selector navigation is a GET, so the per-page nonce rides along
1023 + // as a query parameter and gates the user_meta persist in
1024 + // Metasync_Per_Page_Helper::resolve().
1025 + wp_localize_script(
1026 + $this->plugin_name . '-per-page',
1027 + 'metasyncPerPage',
1028 + array('nonces' => Metasync_Per_Page_Helper::page_nonces())
1029 + );
1030 + }
1031 +
869 1032 // Dashboard iframe height (only on dashboard page)
870 1033 if ($current_page === self::$page_slug . '-dashboard' || $current_page === self::$page_slug) {
871 1034 wp_enqueue_script(
872 1035 $this->plugin_name . '-iframe',
@@ -915,12 +1078,12 @@
915 1078 true
916 1079 );
917 1080 // Pass the active tab from server (handles POST redirect)
918 1081 $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']));
1082 + if (isset($_GET['tab']) && in_array($_GET['tab'], ['general', 'news', 'video'], true)) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed tab selection for the UI
1083 + $sitemap_active_tab = sanitize_text_field(wp_unslash($_GET['tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed tab selection for the UI
1084 + } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only, allow-listed tab selection for the UI
1085 + $sitemap_active_tab = sanitize_text_field(wp_unslash($_POST['redirect_tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only, allow-listed tab selection for the UI
923 1086 }
924 1087 wp_localize_script($this->plugin_name . '-sitemap-tabs', 'metasyncSitemapTabs', [
925 1088 'activeTab' => $sitemap_active_tab,
926 1089 ]);
@@ -1051,9 +1214,8 @@
1051 1214 true
1052 1215 );
1053 1216 wp_localize_script($this->plugin_name . '-connect', 'metasyncConnectData', array(
1054 1217 'optionKey' => self::option_key,
1055 - 'storedPassword' => isset($whitelabel_data['settings_password']) ? $whitelabel_data['settings_password'] : '',
1056 1218 'adminPostUrl' => admin_url('admin-post.php'),
1057 1219 'exportNonce' => wp_create_nonce('metasync_export_whitelabel'),
1058 1220 'ajaxUrl' => admin_url('admin-ajax.php'),
1059 1221 'logoutNonceField' => wp_nonce_field('whitelabel_logout_nonce', 'whitelabel_logout_nonce', true, false),
@@ -1249,8 +1411,10 @@
1249 1411 'nonce' => wp_create_nonce('metasync_nonce'),
1250 1412 'sa_connect_nonce' => $sa_connect_nonce,
1251 1413 'reset_auth_nonce' => wp_create_nonce('metasync_reset_auth_nonce'),
1252 1414 'burst_ping_nonce' => wp_create_nonce('metasync_burst_ping'),
1415 + 'clear_otto_cache_nonce' => wp_create_nonce('metasync_clear_otto_cache'),
1416 + 'lglogin_nonce' => wp_create_nonce('metasync_lglogin'),
1253 1417 'heartbeat_state' => $heartbeat_state,
1254 1418 'dashboard_domain' => self::get_effective_dashboard_domain(),
1255 1419 'support_email' => Metasync::SUPPORT_EMAIL,
1256 1420 'documentation_domain' => Metasync::DOCUMENTATION_DOMAIN,
@@ -1314,9 +1478,9 @@
1314 1478 add_action('admin_notices', array($this, 'display_page_builder_notice'));
1315 1479 // Display update warning banner if plugin update is available
1316 1480 add_action('admin_notices', array($this, 'display_update_warning_banner'));
1317 1481 // Enqueue wizard assets if on wizard page
1318 - if (isset($_GET['page']) && strpos($_GET['page'], '-setup-wizard') !== false) {
1482 + if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-setup-wizard') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick scripts
1319 1483 wp_enqueue_script(
1320 1484 $this->plugin_name . '-setup-wizard',
1321 1485 plugin_dir_url(__FILE__) . 'js/metasync-setup-wizard.js',
1322 1486 array('jquery'),
@@ -1340,14 +1504,44 @@
1340 1504 }
1341 1505
1342 1506 /**
1343 1507 * Settings of HeartBeat API for admin area.
1344 - * Set time interval of send request.
1508 + *
1509 + * The only thing the plugin rides the beat for is a periodic admin
1510 + * telemetry ping (the `heartbeat-send` handler in admin/js/metasync-admin.js),
1511 + * so it slows the beat down rather than speeding it up. Two contexts are
1512 + * left on whatever core decided, because core leans on the beat there:
1513 + *
1514 + * - The front end, where the beat drives wp-auth-check — the "your session
1515 + * has expired, log in again" prompt.
1516 + * - The editor screens, where each tick renews the post lock. A lock only
1517 + * lives for wp_check_post_lock_window() seconds (150 by default), so any
1518 + * interval longer than that drops the lock between beats and lets a
1519 + * second editor silently take over a post someone still has open.
1520 + *
1521 + * Core clamps this to 1-3600 seconds in wp-includes/js/heartbeat.js, so an
1522 + * over-long interval is not corrected on our behalf.
1523 + *
1524 + * @param array $settings Heartbeat settings array.
1525 + * @return array Heartbeat settings array.
1345 1526 */
1346 1527 function metasync_heartbeat_settings($settings)
1347 1528 {
1348 - global $heartbeat_frequency;
1349 - $settings['interval'] = 300;
1529 + if (!is_admin()) {
1530 + return $settings;
1531 + }
1532 +
1533 + # $pagenow is set in wp-includes/vars.php, long before this filter runs;
1534 + # core's own wp_heartbeat_set_suspension() reads it exactly this way.
1535 + global $pagenow;
1536 + $lock_dependent_screens = array('post.php', 'post-new.php', 'customize.php');
1537 + if (isset($pagenow) && in_array($pagenow, $lock_dependent_screens, true)) {
1538 + return $settings;
1539 + }
1540 +
1541 + # 60s matches heartbeat.js's own default and stays well inside the
1542 + # 150s post-lock window, so nothing core does can fall through a gap.
1543 + $settings['interval'] = 60;
1350 1544 return $settings;
1351 1545 }
1352 1546
1353 1547 /**
@@ -1485,13 +1679,8 @@
1485 1679 {
1486 1680 Metasync_Connect_Manager::instance()->test_searchatlas_ajax_endpoint();
1487 1681 }
1488 1682
1489 - public function simple_ajax_test()
1490 - {
1491 - Metasync_Connect_Manager::instance()->simple_ajax_test();
1492 - }
1493 -
1494 1683 /**
1495 1684 * Create Admin Dashboard Iframe Page
1496 1685 * Embeds the Search Atlas dashboard directly in WordPress admin
1497 1686 */
@@ -1518,11 +1707,11 @@
1518 1707
1519 1708 ?>
1520 1709 <!-- OTTO Cache TTL Setting -->
1521 1710 <div style="margin-bottom: 30px; padding-top: 20px;">
1522 - <h4 style="margin-top: 0; color: var(--dashboard-text-primary);">OTTO Cache TTL</h4>
1711 + <h4 style="margin-top: 0; color: var(--dashboard-text-primary);"><?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> Cache TTL</h4>
1523 1712 <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.
1713 + Configure how long <?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> API suggestions are cached before a fresh API call. The stale fallback expires after 12 hours.
1525 1714 </p>
1526 1715
1527 1716 <div style="background: rgba(255,255,255,0.02); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
1528 1717 <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
@@ -1528,8 +1717,9 @@
1528 1717 <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
1529 1718 <label for="metasync-otto-cache-ttl" style="color: var(--dashboard-text-primary); font-weight: 500;">
1530 1719 Cache TTL (minutes):
1531 1720 </label>
1721 + <?php Metasync::render_tooltip_icon('otto_cache_ttl', 'How many minutes OTTO reuses its last SEO suggestions before fetching fresh ones. 30 is fine for most sites.'); ?>
1532 1722 <input type="number"
1533 1723 id="metasync-otto-cache-ttl"
1534 1724 value="<?php echo esc_attr($this->get_otto_cache_ttl_minutes()); ?>"
1535 1725 min="30"
@@ -1577,9 +1767,9 @@
1577 1767 } else {
1578 1768 echo '<p style="color: var(--dashboard-text-secondary);">ℹ️ No cache plugins detected.</p>';
1579 1769 }
1580 1770 } catch (Exception $e) {
1581 - error_log('MetaSync Cache Status Error: ' . $e->getMessage());
1771 + error_log('MetaSync Cache Status Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1582 1772 echo '<p style="color: var(--dashboard-error);">⚠️ An error occurred while retrieving cache plugin status.</p>';
1583 1773 }
1584 1774 } else {
1585 1775 echo '<p style="color: var(--dashboard-error);">⚠️ Cache Purge class not loaded.</p>';
@@ -1596,12 +1786,12 @@
1596 1786 </form>
1597 1787
1598 1788 <?php
1599 1789 // 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'])) : '';
1790 + if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1791 + $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1792 + $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1793 + $plugins = isset($_GET['plugins']) ? sanitize_text_field(wp_unslash($_GET['plugins'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1604 1794
1605 1795 if ($cleared > 0) {
1606 1796 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1607 1797 echo '✅ <strong>Success!</strong> Cleared cache for ' . intval( $cleared ) . ' plugin(s)';
@@ -1621,10 +1811,10 @@
1621 1811 echo '</p></div>';
1622 1812 }
1623 1813 }
1624 1814
1625 - if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') {
1626 - $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : '';
1815 + if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1816 + $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1627 1817 if (empty($message)) {
1628 1818 $message = 'An unknown error occurred while clearing cache. Please check error logs for details.';
1629 1819 }
1630 1820 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
@@ -1673,8 +1863,9 @@
1673 1863 <?php checked(true, !empty($hosting_settings['wpengine_enabled'])); ?>
1674 1864 <?php echo !$wpe_detected ? 'disabled' : ''; ?>
1675 1865 style="width: 16px; height: 16px; cursor: <?php echo $wpe_detected ? 'pointer' : 'not-allowed'; ?>;" />
1676 1866 <span style="color: var(--dashboard-text-primary); font-weight: 500;">WP Engine</span>
1867 + <?php Metasync::render_tooltip_icon('hosting_cache_wpengine', 'If hosted on WP Engine, turn on so OTTO clears the host\'s built-in cache when it updates a page.'); ?>
1677 1868 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges Varnish + Memcached</span>
1678 1869 </label>
1679 1870
1680 1871 <label style="display: flex; align-items: center; gap: 10px; margin-bottom: 16px; cursor: pointer;">
@@ -1683,8 +1874,9 @@
1683 1874 <?php checked(true, !empty($hosting_settings['kinsta_enabled'])); ?>
1684 1875 <?php echo !$kinsta_detected ? 'disabled' : ''; ?>
1685 1876 style="width: 16px; height: 16px; cursor: <?php echo $kinsta_detected ? 'pointer' : 'not-allowed'; ?>;" />
1686 1877 <span style="color: var(--dashboard-text-primary); font-weight: 500;">Kinsta</span>
1878 + <?php Metasync::render_tooltip_icon('hosting_cache_kinsta', 'If hosted on Kinsta, turn on so OTTO clears Kinsta\'s server cache automatically.'); ?>
1687 1879 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges full-page cache (kinsta_cache_purge_full)</span>
1688 1880 </label>
1689 1881
1690 1882 <div style="display: flex; align-items: center; gap: 12px;">
@@ -1719,12 +1911,13 @@
1719 1911 </form>
1720 1912
1721 1913 <?php
1722 1914 // 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'])) : '';
1915 + if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1916 + // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each value is unslashed and sanitized on the following assignments.
1917 + $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_cleared']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1918 + $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_failed']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1919 + $hc_not_detected = isset($_GET['hc_not_detected']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_not_detected']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1727 1920
1728 1921 if ($hc_cleared) {
1729 1922 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1730 1923 echo '✅ <strong>Success!</strong> Purged hosting cache on: ' . esc_html($hc_cleared);
@@ -1739,8 +1932,9 @@
1739 1932 echo '<div class="notice notice-info inline" style="margin-top: 10px;"><p>';
1740 1933 echo 'ℹ️ No enabled hosting providers were detected on this server (' . esc_html($hc_not_detected) . ').';
1741 1934 echo '</p></div>';
1742 1935 }
1936 + // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1743 1937 }
1744 1938 ?>
1745 1939
1746 1940 <script>
@@ -1832,9 +2026,9 @@
1832 2026 id="metasync-targeted-object-cache"
1833 2027 <?php checked('1', $targeted_cache_enabled); ?>
1834 2028 style="width: 16px; height: 16px; cursor: pointer; margin-top: 2px; flex-shrink: 0;" />
1835 2029 <span>
1836 - <span style="color: var(--dashboard-text-primary); font-weight: 500; display: block; margin-bottom: 4px;">Targeted Object Cache Purge</span>
2030 + <span style="color: var(--dashboard-text-primary); font-weight: 500; display: block; margin-bottom: 4px;">Targeted Object Cache Purge<?php Metasync::render_tooltip_icon('targeted_object_cache_purge', 'Leave this on. Refreshes only the pages OTTO changed instead of wiping your whole site\'s memory cache — safer/faster on large sites.'); ?></span>
1837 2031 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">
1838 2032 When enabled, only the updated posts are evicted from the object cache (recommended for large sites).
1839 2033 When disabled, a full <code>wp_cache_flush()</code> is used instead.
1840 2034 </span>
@@ -1905,11 +2099,11 @@
1905 2099 </div>
1906 2100
1907 2101 <?php
1908 2102 // 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']))) : '';
2103 + if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2104 + $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2105 + $url = isset($_GET['url']) ? urldecode(sanitize_text_field(wp_unslash($_GET['url']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
1912 2106
1913 2107 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1914 2108 if (!empty($url)) {
1915 2109 echo '✅ <strong>Success!</strong> Cleared cache for URL: <code>' . esc_html($url) . '</code> (' . intval( $cleared_count ) . ' entries)';
@@ -1918,10 +2112,10 @@
1918 2112 }
1919 2113 echo '</p></div>';
1920 2114 }
1921 2115
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.';
2116 + if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2117 + $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : 'An unknown error occurred.'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
1924 2118 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
1925 2119 echo '❌ <strong>Error:</strong> ' . esc_html($message);
1926 2120 echo '</p></div>';
1927 2121 }
@@ -1960,9 +2154,9 @@
1960 2154 <td>
1961 2155 <input type="url"
1962 2156 id="otto_cache_url"
1963 2157 name="otto_cache_url"
1964 - value="<?php echo isset($_GET['url']) ? esc_attr(urldecode(sanitize_text_field(wp_unslash($_GET['url'])))) : ''; ?>"
2158 + value="<?php echo isset($_GET['url']) ? esc_attr(urldecode(sanitize_text_field(wp_unslash($_GET['url'])))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce ?>"
1965 2159 class="regular-text"
1966 2160 placeholder="https://example.com/page/"
1967 2161 required />
1968 2162 <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 +2227,10 @@
2033 2227 /**
2034 2228 * AJAX handler for saving hosting cache settings
2035 2229 */
2036 2230 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')) {
2231 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2232 + if (!isset($_POST['hosting_cache_nonce']) || !wp_verify_nonce(wp_unslash($_POST['hosting_cache_nonce']), 'metasync_hosting_cache_nonce')) {
2038 2233 wp_send_json_error(array('message' => 'Security check failed'));
2039 2234 return;
2040 2235 }
2041 2236
@@ -2056,9 +2251,10 @@
2056 2251 /**
2057 2252 * AJAX handler: save object cache behaviour settings
2058 2253 */
2059 2254 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')) {
2255 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2256 + if (!isset($_POST['object_cache_nonce']) || !wp_verify_nonce(wp_unslash($_POST['object_cache_nonce']), 'metasync_object_cache_nonce')) {
2061 2257 wp_send_json_error(array('message' => 'Security check failed'));
2062 2258 return;
2063 2259 }
2064 2260
@@ -2085,9 +2281,10 @@
2085 2281 /**
2086 2282 * AJAX handler for saving OTTO Cache TTL
2087 2283 */
2088 2284 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')) {
2285 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2286 + if (!isset($_POST['otto_cache_ttl_nonce']) || !wp_verify_nonce(wp_unslash($_POST['otto_cache_ttl_nonce']), 'metasync_otto_cache_ttl_nonce')) {
2090 2287 wp_send_json_error(array('message' => 'Security check failed.'));
2091 2288 return;
2092 2289 }
2093 2290
@@ -2098,9 +2295,9 @@
2098 2295
2099 2296 $ttl = isset($_POST['otto_cache_ttl']) ? absint($_POST['otto_cache_ttl']) : 30;
2100 2297
2101 2298 if ($ttl < 30 || $ttl > 1440) {
2102 - wp_send_json_error(array('message' => 'OTTO Cache TTL must be between 30 and 1440 minutes.'));
2299 + wp_send_json_error(array('message' => sprintf('%s Cache TTL must be between 30 and 1440 minutes.', Metasync::get_whitelabel_otto_name())));
2103 2300 return;
2104 2301 }
2105 2302
2106 2303 $settings = get_option('metasync_execution_settings', array());
@@ -2106,9 +2303,9 @@
2106 2303 $settings = get_option('metasync_execution_settings', array());
2107 2304 $settings['otto_cache_ttl'] = $ttl;
2108 2305 update_option('metasync_execution_settings', $settings);
2109 2306
2110 - wp_send_json_success(array('message' => 'OTTO Cache TTL saved.'));
2307 + wp_send_json_success(array('message' => sprintf('%s Cache TTL saved.', Metasync::get_whitelabel_otto_name())));
2111 2308 }
2112 2309
2113 2310 /**
2114 2311 * admin_post handler: purge WP Engine and Kinsta hosting-level caches
@@ -2113,9 +2310,10 @@
2113 2310 /**
2114 2311 * admin_post handler: purge WP Engine and Kinsta hosting-level caches
2115 2312 */
2116 2313 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')) {
2314 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2315 + if (!isset($_POST['hosting_cache_purge_nonce']) || !wp_verify_nonce(wp_unslash($_POST['hosting_cache_purge_nonce']), 'metasync_hosting_cache_purge_nonce')) {
2118 2316 wp_die('Security check failed');
2119 2317 }
2120 2318
2121 2319 if (!Metasync::current_user_has_plugin_access()) {
@@ -2135,9 +2333,9 @@
2135 2333 WpeCommon::purge_varnish_cache();
2136 2334 WpeCommon::purge_memcached();
2137 2335 $cleared[] = 'WP Engine';
2138 2336 } catch (Exception $e) {
2139 - error_log('MetaSync: WP Engine hosting cache purge failed - ' . $e->getMessage());
2337 + error_log('MetaSync: WP Engine hosting cache purge failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2140 2338 $failed[] = 'WP Engine';
2141 2339 }
2142 2340 } else {
2143 2341 $not_detected[] = 'WP Engine';
@@ -2150,9 +2348,9 @@
2150 2348 try {
2151 2349 KinstaCache::get_instance()->kinsta_cache_purge_full();
2152 2350 $cleared[] = 'Kinsta';
2153 2351 } catch (Exception $e) {
2154 - error_log('MetaSync: Kinsta hosting cache purge failed - ' . $e->getMessage());
2352 + error_log('MetaSync: Kinsta hosting cache purge failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2155 2353 $failed[] = 'Kinsta';
2156 2354 }
2157 2355 } else {
2158 2356 $not_detected[] = 'Kinsta';
@@ -2547,9 +2745,9 @@
2547 2745 # Initialize MetaSync API request class and trigger synchronization
2548 2746 (new Metasync_Sync_Requests())->SyncCustomerParams();
2549 2747 } catch (Exception $e) {
2550 2748 # Log any API request errors for debugging
2551 - error_log('Metasync API Error: ' . $e->getMessage());
2749 + error_log('Metasync API Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2552 2750 }
2553 2751 }
2554 2752
2555 2753 /*
@@ -2564,9 +2762,9 @@
2564 2762 # Initialize MetaSync API request class and trigger synchronization
2565 2763 (new Metasync_Sync_Requests())->SyncCustomerParams();
2566 2764 } catch (Exception $e) {
2567 2765 # Log any API request errors for debugging
2568 - error_log('Metasync API Error: ' . $e->getMessage());
2766 + error_log('Metasync API Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2569 2767 }
2570 2768 }
2571 2769
2572 2770 /**
@@ -2599,8 +2797,15 @@
2599 2797 // Handle form submissions
2600 2798 if (isset($_POST['metasync_media_optimization_nonce'])) {
2601 2799 check_admin_referer('metasync_save_media_optimization', 'metasync_media_optimization_nonce');
2602 2800
2801 + // Settings shape the whole site's conversion pipeline — restrict
2802 + // the save (and the reset) to site admins, not just anyone who can
2803 + // open the page via plugin_access_roles.
2804 + if (!current_user_can('manage_options')) {
2805 + wp_die(esc_html__('Sorry, you are not allowed to manage media optimization settings.', 'metasync'));
2806 + }
2807 +
2603 2808 // Handle reset to defaults
2604 2809 if (!empty($_POST['metasync_media_reset'])) {
2605 2810 $defaults = Metasync_Media_Settings::get_defaults();
2606 2811 Metasync_Media_Settings::save_settings($defaults);
@@ -2605,8 +2810,9 @@
2605 2810 $defaults = Metasync_Media_Settings::get_defaults();
2606 2811 Metasync_Media_Settings::save_settings($defaults);
2607 2812 $save_success = true;
2608 2813 } elseif (isset($_POST['metasync_media'])) {
2814 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Media_Settings::save_settings().
2609 2815 $input = wp_unslash($_POST['metasync_media']);
2610 2816 Metasync_Media_Settings::save_settings($input);
2611 2817 $save_success = true;
2612 2818 }
@@ -2638,8 +2844,15 @@
2638 2844 * Code Minification page callback
2639 2845 */
2640 2846 public function create_admin_code_minification_page()
2641 2847 {
2848 + // Settings shape the whole site's asset pipeline — restrict
2849 + // the save (and the reset) to site admins, not just anyone who can
2850 + // open the page via plugin_access_roles.
2851 + if (!empty($_POST) && !current_user_can('manage_options')) {
2852 + wp_die(esc_html__('Sorry, you are not allowed to manage code minification settings.', 'metasync'));
2853 + }
2854 +
2642 2855 $this->render_layout_open('Code Minification', 'code_minification', 'Minify CSS, JavaScript, and HTML to improve performance.');
2643 2856 // Load settings and compatibility classes
2644 2857 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-settings.php';
2645 2858 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-cache.php';
@@ -2656,8 +2869,9 @@
2656 2869 $defaults = Metasync_Minification_Settings::get_defaults();
2657 2870 Metasync_Minification_Settings::save_settings($defaults);
2658 2871 $save_success = true;
2659 2872 } elseif (isset($_POST['metasync_code_min'])) {
2873 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Minification_Settings::save_settings().
2660 2874 $input = (array) wp_unslash($_POST['metasync_code_min']);
2661 2875 Metasync_Minification_Settings::save_settings($input);
2662 2876 $save_success = true;
2663 2877 }
@@ -2690,8 +2904,15 @@
2690 2904 if (!$attachment_id) {
2691 2905 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2692 2906 }
2693 2907
2908 + // Ownership: under the replace strategy this permanently deletes the
2909 + // attachment's original file, so upload_files alone is not enough —
2910 + // the caller must be allowed to edit this specific attachment.
2911 + if (!current_user_can('edit_post', $attachment_id)) {
2912 + wp_send_json_error(__('Permission denied.', 'metasync'));
2913 + }
2914 +
2694 2915 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-settings.php';
2695 2916 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-image-converter.php';
2696 2917 $settings = Metasync_Media_Settings::get_settings();
2697 2918
@@ -2705,8 +2926,9 @@
2705 2926 $max_bytes = 10 * 1024 * 1024;
2706 2927 if (filesize($file) > $max_bytes) {
2707 2928 $size_mb = round(filesize($file) / 1024 / 1024, 1);
2708 2929 wp_send_json_error(sprintf(
2930 + /* translators: %s: file size in megabytes. */
2709 2931 __('Optimization skipped: file size (%s MB) exceeds the 10 MB safety limit to prevent memory issues.', 'metasync'),
2710 2932 $size_mb
2711 2933 ));
2712 2934 }
@@ -2743,8 +2965,20 @@
2743 2965 if (!$attachment_id) {
2744 2966 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2745 2967 }
2746 2968
2969 + // Ownership: reverting deletes the attachment's converted files, so
2970 + // restrict it to users who may edit this specific attachment.
2971 + if (!current_user_can('edit_post', $attachment_id)) {
2972 + wp_send_json_error(__('Permission denied.', 'metasync'));
2973 + }
2974 +
2975 + // Replace-strategy conversions have no original to restore — reverting
2976 + // would delete the attachment's only file. Refuse up front, like bulk revert does.
2977 + if (!Metasync_Image_Converter::can_revert($attachment_id)) {
2978 + wp_send_json_error(__('This image cannot be reverted. The original file no longer exists (replace strategy).', 'metasync'));
2979 + }
2980 +
2747 2981 $success = Metasync_Image_Converter::revert_attachment($attachment_id);
2748 2982
2749 2983 if ($success) {
2750 2984 wp_send_json_success();
@@ -2796,8 +3030,15 @@
2796 3030 public function ajax_batch_progress()
2797 3031 {
2798 3032 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
2799 3033
3034 + // Read-only progress/stats polling — the same capability that can
3035 + // open the media optimization page is enough, but a nonce alone
3036 + // never was.
3037 + if (!current_user_can('upload_files')) {
3038 + wp_send_json_error(__('Permission denied.', 'metasync'));
3039 + }
3040 +
2800 3041 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-batch-optimizer.php';
2801 3042 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
2802 3043
2803 3044 $progress = Metasync_Media_Batch_Optimizer::get_progress();
@@ -2828,10 +3069,19 @@
2828 3069 $settings = Metasync_Media_Settings::get_settings();
2829 3070
2830 3071 $success = 0;
2831 3072 $failed = 0;
3073 + $denied = 0;
2832 3074
2833 3075 foreach ($ids as $id) {
3076 + // Per-attachment ownership: replace mode deletes originals, so
3077 + // upload_files alone must not allow converting images the caller
3078 + // does not own/cannot edit.
3079 + if (!current_user_can('edit_post', $id)) {
3080 + $denied++;
3081 + continue;
3082 + }
3083 +
2834 3084 if (Metasync_Image_Converter::convert_attachment($id, $settings)) {
2835 3085 $success++;
2836 3086 } else {
2837 3087 $failed++;
@@ -2840,8 +3090,9 @@
2840 3090
2841 3091 wp_send_json_success([
2842 3092 'success' => $success,
2843 3093 'failed' => $failed,
3094 + 'denied' => $denied,
2844 3095 ]);
2845 3096 }
2846 3097
2847 3098 /**
@@ -2864,11 +3115,19 @@
2864 3115
2865 3116 $success = 0;
2866 3117 $failed = 0;
2867 3118 $skipped = 0;
3119 + $denied = 0;
2868 3120 $errors = [];
2869 3121
2870 3122 foreach ($ids as $id) {
3123 + // Per-attachment ownership: reverting deletes the attachment's
3124 + // converted files, so restrict it per image, not per role.
3125 + if (!current_user_can('edit_post', $id)) {
3126 + $denied++;
3127 + continue;
3128 + }
3129 +
2871 3130 $format = get_post_meta($id, '_metasync_converted_format', true);
2872 3131
2873 3132 if (!$format) {
2874 3133 $skipped++;
@@ -2878,8 +3137,9 @@
2878 3137 if (!Metasync_Image_Converter::can_revert($id)) {
2879 3138 $skipped++;
2880 3139 $file = get_attached_file($id);
2881 3140 $name = $file ? basename($file) : "ID {$id}";
3141 + /* translators: %s: attachment file name. */
2882 3142 $errors[] = sprintf(__('%s: skipped — original image unavailable.', 'metasync'), $name);
2883 3143 continue;
2884 3144 }
2885 3145
@@ -2888,8 +3148,9 @@
2888 3148 } else {
2889 3149 $failed++;
2890 3150 $file = get_attached_file($id);
2891 3151 $name = $file ? basename($file) : "ID {$id}";
3152 + /* translators: %s: attachment file name. */
2892 3153 $errors[] = sprintf(__('%s: revert failed (original file may not exist).', 'metasync'), $name);
2893 3154 }
2894 3155 }
2895 3156
@@ -2896,8 +3157,9 @@
2896 3157 wp_send_json_success([
2897 3158 'success' => $success,
2898 3159 'failed' => $failed,
2899 3160 'skipped' => $skipped,
3161 + 'denied' => $denied,
2900 3162 'errors' => $errors,
2901 3163 ]);
2902 3164 }
2903 3165
@@ -2921,8 +3183,49 @@
2921 3183 wp_send_json_success($progress);
2922 3184 }
2923 3185
2924 3186 /**
3187 + * AJAX: Delete an orphaned media record (attachment whose file is missing).
3188 + *
3189 + * Only deletes when the file is confirmed missing on disk, so valid
3190 + * attachments can never be removed through this endpoint.
3191 + */
3192 + public function ajax_delete_orphaned_image()
3193 + {
3194 + check_ajax_referer('metasync_media_opt_nonce', 'nonce');
3195 +
3196 + $attachment_id = isset($_POST['attachment_id']) ? absint($_POST['attachment_id']) : 0;
3197 + if (!$attachment_id) {
3198 + wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
3199 + }
3200 +
3201 + if (!current_user_can('delete_post', $attachment_id)) {
3202 + wp_send_json_error(__('Permission denied.', 'metasync'));
3203 + }
3204 +
3205 + if (get_post_type($attachment_id) !== 'attachment') {
3206 + wp_send_json_error(__('Not an attachment.', 'metasync'));
3207 + }
3208 +
3209 + require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
3210 +
3211 + // Guard: only orphaned records (missing file) may be deleted here.
3212 + if (!Metasync_Media_Library_List_Table::is_file_missing($attachment_id)) {
3213 + wp_send_json_error(__('The image file exists; refusing to delete a valid attachment.', 'metasync'));
3214 + }
3215 +
3216 + $deleted = wp_delete_attachment($attachment_id, true);
3217 +
3218 + if ($deleted) {
3219 + wp_send_json_success([
3220 + 'stats' => Metasync_Media_Library_List_Table::get_stats(),
3221 + ]);
3222 + }
3223 +
3224 + wp_send_json_error(__('Failed to delete the orphaned media record.', 'metasync'));
3225 + }
3226 +
3227 + /**
2925 3228 * Cron handler: Process batch optimization tick.
2926 3229 */
2927 3230 public function handle_media_batch_cron()
2928 3231 {
@@ -2932,8 +3235,86 @@
2932 3235 Metasync_Media_Batch_Optimizer::process_batch_tick();
2933 3236 }
2934 3237
2935 3238 /**
3239 + * AJAX: Start SEO Restore batch.
3240 + */
3241 + public function ajax_seo_restore_start()
3242 + {
3243 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3244 +
3245 + if (!current_user_can('manage_options')) {
3246 + wp_send_json_error(__('Permission denied.', 'metasync'));
3247 + }
3248 +
3249 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3250 +
3251 + $progress = Metasync_Seo_Restore::start_batch();
3252 + wp_send_json_success($progress);
3253 + }
3254 +
3255 + /**
3256 + * AJAX: Cancel SEO Restore batch.
3257 + */
3258 + public function ajax_seo_restore_cancel()
3259 + {
3260 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3261 +
3262 + if (!current_user_can('manage_options')) {
3263 + wp_send_json_error(__('Permission denied.', 'metasync'));
3264 + }
3265 +
3266 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3267 +
3268 + Metasync_Seo_Restore::cancel_batch();
3269 + wp_send_json_success();
3270 + }
3271 +
3272 + /**
3273 + * AJAX: Get SEO Restore batch progress.
3274 + */
3275 + public function ajax_seo_restore_progress()
3276 + {
3277 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3278 +
3279 + if (!current_user_can('manage_options')) {
3280 + wp_send_json_error(__('Permission denied.', 'metasync'));
3281 + }
3282 +
3283 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3284 +
3285 + $progress = Metasync_Seo_Restore::get_progress();
3286 + wp_send_json_success($progress);
3287 + }
3288 +
3289 + /**
3290 + * AJAX: Process one SEO Restore tick (browser-driven chaining).
3291 + */
3292 + public function ajax_seo_restore_process_tick()
3293 + {
3294 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3295 +
3296 + if (!current_user_can('manage_options')) {
3297 + wp_send_json_error(__('Permission denied.', 'metasync'));
3298 + }
3299 +
3300 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3301 +
3302 + $progress = Metasync_Seo_Restore::process_ajax_tick();
3303 + wp_send_json_success($progress);
3304 + }
3305 +
3306 + /**
3307 + * Cron handler: Process SEO Restore batch tick.
3308 + */
3309 + public function handle_seo_restore_cron()
3310 + {
3311 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3312 +
3313 + Metasync_Seo_Restore::process_batch_tick();
3314 + }
3315 +
3316 + /**
2936 3317 * Report Issue page callback
2937 3318 */
2938 3319 public function create_admin_report_issue_page()
2939 3320 {
@@ -2945,8 +3326,17 @@
2945 3326 * XML Sitemap page callback
2946 3327 */
2947 3328 public function create_admin_xml_sitemap_page()
2948 3329 {
3330 + // Every form action below mutates site-wide state — sitemap options,
3331 + // sitemap files on disk, robots.txt, other plugins' sitemap generators
3332 + // — so they are restricted to site admins, not just anyone who can
3333 + // open the page via plugin_access_roles. The nonce is CSRF
3334 + // protection, not authorization.
3335 + if (!empty($_POST) && !current_user_can('manage_options')) {
3336 + wp_die(esc_html__('Sorry, you are not allowed to manage sitemap settings.', 'metasync'));
3337 + }
3338 +
2949 3339 // Load sitemap generator class
2950 3340 require_once plugin_dir_path(dirname(__FILE__)) . 'sitemap/class-metasync-sitemap-generator.php';
2951 3341
2952 3342 $sitemap_generator = new Metasync_Sitemap_Generator();
@@ -2974,13 +3364,16 @@
2974 3364
2975 3365 update_option('metasync_sitemap_settings', $sitemap_settings);
2976 3366
2977 3367 // Regenerate sitemap with new content settings
2978 - $result = $sitemap_generator->generate_sitemap();
3368 + $result = $sitemap_generator->generate_sitemap( true );
2979 3369 if (is_wp_error($result)) {
2980 3370 echo '<div class="notice notice-error"><p>' . esc_html(
3371 + /* translators: %s: error message. */
2981 3372 sprintf(__('Settings saved but sitemap generation failed: %s', 'metasync'), $result->get_error_message())
2982 3373 ) . '</p></div>';
3374 + } elseif (false === $result) {
3375 + echo '<div class="notice notice-error"><p>' . esc_html__('Settings saved but sitemap generation failed: the sitemap data could not be stored, so no sitemap is being served. Check your object cache and database write settings, then try again.', 'metasync') . '</p></div>';
2983 3376 } else {
2984 3377 echo '<div class="notice notice-success"><p>' . esc_html__('Sitemap content settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
2985 3378 }
2986 3379 }
@@ -2991,10 +3384,10 @@
2991 3384
2992 3385 // Build generic taxonomy filters
2993 3386 $news_taxonomies = [];
2994 3387 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);
3388 + foreach (wp_unslash($_POST['news_taxonomies']) as $tax_name => $term_ids) {
3389 + $news_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) wp_unslash($term_ids));
2997 3390 }
2998 3391 }
2999 3392
3000 3393 $news_settings = [
@@ -3019,9 +3412,9 @@
3019 3412 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3020 3413 } else {
3021 3414 // Also remove physical file if it exists
3022 3415 if (file_exists(ABSPATH . 'news-sitemap.xml')) {
3023 - @unlink(ABSPATH . 'news-sitemap.xml');
3416 + @wp_delete_file(ABSPATH . 'news-sitemap.xml');
3024 3417 }
3025 3418 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
3026 3419 }
3027 3420 }
@@ -3032,10 +3425,10 @@
3032 3425
3033 3426 // Build generic taxonomy filters
3034 3427 $video_taxonomies = [];
3035 3428 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);
3429 + foreach (wp_unslash($_POST['video_taxonomies']) as $tax_name => $term_ids) {
3430 + $video_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) wp_unslash($term_ids));
3038 3431 }
3039 3432 }
3040 3433
3041 3434 $video_settings = [
@@ -3043,8 +3436,12 @@
3043 3436 'post_types' => array_map('sanitize_key', (array) ($_POST['video_post_types'] ?? ['post', 'page'])),
3044 3437 'auto_detect' => isset($_POST['auto_detect']),
3045 3438 'taxonomies' => $video_taxonomies,
3046 3439 'excluded_urls' => sanitize_textarea_field(wp_unslash($_POST['video_excluded_urls'] ?? '')),
3440 + // This array replaces the stored option wholesale, so every
3441 + // setting must be represented here or it is wiped on save.
3442 + 'video_url_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_url_meta_keys'] ?? '')),
3443 + 'video_thumbnail_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_thumbnail_meta_keys'] ?? '')),
3047 3444 ];
3048 3445
3049 3446 // Always invalidate old cache before saving new settings
3050 3447 delete_transient('metasync_vsm_' . md5('video-sitemap.xml'));
@@ -3057,9 +3454,9 @@
3057 3454 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3058 3455 } else {
3059 3456 // Also remove physical file if it exists
3060 3457 if (file_exists(ABSPATH . 'video-sitemap.xml')) {
3061 - @unlink(ABSPATH . 'video-sitemap.xml');
3458 + @wp_delete_file(ABSPATH . 'video-sitemap.xml');
3062 3459 }
3063 3460 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
3064 3461 }
3065 3462 }
@@ -3102,8 +3499,17 @@
3102 3499 if ($sitemap_generator->generate_news_sitemap()) {
3103 3500 $extras[] = 'news';
3104 3501 }
3105 3502 }
3503 +
3504 + // Reclaim unreferenced memory between the news and video passes
3505 + // so the video generator starts with more headroom (they share
3506 + // one request). Each generator already releases its own object
3507 + // cache via clean_post_cache(), so no cache flush is needed here.
3508 + if (function_exists('gc_collect_cycles')) {
3509 + gc_collect_cycles();
3510 + }
3511 +
3106 3512 if (!empty($video_opts['enabled'])) {
3107 3513 if ($sitemap_generator->generate_video_sitemap()) {
3108 3514 $extras[] = 'video';
3109 3515 }
@@ -3108,21 +3514,30 @@
3108 3514 $extras[] = 'video';
3109 3515 }
3110 3516 }
3111 3517
3518 + // Same inter-pass GC before the main sitemap pass begins.
3519 + if (function_exists('gc_collect_cycles')) {
3520 + gc_collect_cycles();
3521 + }
3522 +
3112 3523 // Generate main sitemap (its index will include news/video since they now exist)
3113 - $result = $sitemap_generator->generate_sitemap();
3524 + $result = $sitemap_generator->generate_sitemap( true );
3114 3525
3115 3526 if (is_wp_error($result)) {
3116 3527 $error_msg = $result->get_error_message();
3117 - error_log('[MetaSync] Sitemap generation failed: ' . $error_msg);
3528 + error_log('[MetaSync] Sitemap generation failed: ' . $error_msg); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3118 3529 echo '<div class="notice notice-error"><p>' . esc_html(
3530 + /* translators: %s: error message. */
3119 3531 sprintf(__('Sitemap generation failed: %s', 'metasync'), $error_msg)
3120 3532 ) . '</p></div>';
3533 + } elseif (false === $result) {
3534 + echo '<div class="notice notice-error"><p>' . esc_html__('Sitemap generation failed: the sitemap data could not be stored, so no sitemap is being served. Check your object cache and database write settings, then try again.', 'metasync') . '</p></div>';
3121 3535 } else {
3122 3536 $message = esc_html__('Sitemap generated successfully!', 'metasync');
3123 3537 if (!empty($extras)) {
3124 3538 $message .= ' ' . sprintf(
3539 + /* translators: %s: list of sitemap types. */
3125 3540 esc_html__('Also generated %s sitemap(s).', 'metasync'),
3126 3541 implode(' & ', $extras)
3127 3542 );
3128 3543 }
@@ -3157,9 +3572,9 @@
3157 3572
3158 3573 if ($deleted) {
3159 3574 // Disable auto-update only when the general sitemap is removed
3160 3575 update_option('metasync_sitemap_auto_update', false);
3161 - // Re-enable WP core sitemap only if no other MetaSync sitemaps remain (WP-396)
3576 + // Re-enable WP core sitemap only if no other MetaSync sitemaps remain
3162 3577 if (!$sitemap_generator->sitemap_exists()) {
3163 3578 delete_option('metasync_disable_wp_sitemap');
3164 3579 }
3165 3580 echo '<div class="notice notice-success"><p>' . esc_html__('General sitemap deleted successfully!', 'metasync') . '</p></div>';
@@ -3188,9 +3603,9 @@
3188 3603
3189 3604 if ($deleted) {
3190 3605 // Also disable auto-update when deleting
3191 3606 update_option('metasync_sitemap_auto_update', false);
3192 - // Re-enable WP core sitemap so the site isn't left with zero sitemaps (WP-396)
3607 + // Re-enable WP core sitemap so the site isn't left with zero sitemaps
3193 3608 delete_option('metasync_disable_wp_sitemap');
3194 3609 echo '<div class="notice notice-success"><p>' . esc_html__('All sitemaps deleted successfully!', 'metasync') . '</p></div>';
3195 3610 } else {
3196 3611 echo '<div class="notice notice-error"><p>' . esc_html__('Failed to delete sitemaps. The files may not exist or are not writable.', 'metasync') . '</p></div>';
@@ -3311,47 +3726,8 @@
3311 3726 Metasync_Admin_Pages::get_instance($this)->create_admin_breadcrumbs_page();
3312 3727 }
3313 3728
3314 3729 /**
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 3730 * Google Console page callback
3355 3731 */
3356 3732 public function create_admin_google_console_page()
3357 3733 {
@@ -3374,16 +3750,8 @@
3374 3750 $this->render_layout_close();
3375 3751 }
3376 3752
3377 3753 /**
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 3754 * Global Options page callback
3387 3755 */
3388 3756 public function create_admin_global_settings_page()
3389 3757 {
@@ -3415,16 +3783,8 @@
3415 3783 Metasync_Admin_Pages::get_instance($this)->create_admin_seo_controls_page();
3416 3784 }
3417 3785
3418 3786 /**
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 3787 * redirection page callback with tabs
3428 3788 */
3429 3789 public function create_admin_redirections_page()
3430 3790 {
@@ -3451,10 +3811,11 @@
3451 3811 }
3452 3812 delete_transient( Metasync_CPU_Monitor::DEFER_NOTICE_TRANSIENT );
3453 3813 echo '<div class="notice notice-warning is-dismissible"><p>';
3454 3814 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' ),
3815 + /* translators: 1: plugin name, 2: current load, 3: threshold, 4: core count */
3816 + 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' ),
3817 + esc_html( Metasync::get_effective_plugin_name() ),
3457 3818 esc_html( $data['load'] ),
3458 3819 esc_html( $data['threshold'] ),
3459 3820 esc_html( $data['cores'] )
3460 3821 );
@@ -3472,9 +3833,10 @@
3472 3833 if (!get_transient('metasync_llms_conflict')) {
3473 3834 return;
3474 3835 }
3475 3836 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');
3837 + /* translators: %s: effective plugin name (whitelabel-aware). */
3838 + echo esc_html(sprintf(__('Note: Another SEO plugin (Yoast, Rank Math, or AIOSEO) may also be generating /llms.txt. %s\'s version takes priority when enabled.', 'metasync'), Metasync::get_effective_plugin_name()));
3477 3839 echo '</p></div>';
3478 3840 }
3479 3841
3480 3842 /**
@@ -3543,8 +3905,17 @@
3543 3905 public function ajax_restore_robots_backup()
3544 3906 {
3545 3907 Metasync_Admin_Ajax::instance()->ajax_restore_robots_backup();
3546 3908 }
3909 +
3910 + /**
3911 + * AJAX handler to fetch a paginated page of robots.txt backups
3912 + */
3913 + public function ajax_get_robots_backups()
3914 + {
3915 + Metasync_Admin_Ajax::instance()->ajax_get_robots_backups();
3916 + }
3917 +
3547 3918 public function ajax_create_redirect_from_404()
3548 3919 {
3549 3920 Metasync_Admin_Ajax::instance()->ajax_create_redirect_from_404();
3550 3921 }
@@ -3572,9 +3943,9 @@
3572 3943 {
3573 3944 register_rest_route('metasync/v1', '/ping', array(
3574 3945 'methods' => array('GET', 'POST'),
3575 3946 'callback' => array($this, 'handle_ping_rest_endpoint'),
3576 - 'permission_callback' => '__return_true', // Allow public access
3947 + 'permission_callback' => '__return_true',
3577 3948 'args' => array(
3578 3949 'test' => array(
3579 3950 'description' => 'Optional test parameter',
3580 3951 'type' => 'string',
@@ -3656,17 +4027,17 @@
3656 4027 return;
3657 4028 }
3658 4029
3659 4030 // Get pagination parameters
3660 - $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
3661 - $per_page = 10;
4031 + $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
4032 + $per_page = Metasync_Per_Page_Helper::resolve('sync_log', 10);
3662 4033 $offset = ($page - 1) * $per_page;
3663 4034
3664 4035 // Get filters
3665 4036 $filters = [
3666 4037 // 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'])) : '',
4038 + 'date_range' => isset($_GET['date_range']) ? sanitize_text_field(wp_unslash($_GET['date_range'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
4039 + 'status' => isset($_GET['status']) ? sanitize_text_field(wp_unslash($_GET['status'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
3669 4040 ];
3670 4041
3671 4042 // Map date_range to concrete date_from/date_to for DB queries
3672 4043 $date_range = $filters['date_range'];
@@ -3675,28 +4046,28 @@
3675 4046 $date_to = '';
3676 4047
3677 4048 if (!empty($date_range)) {
3678 4049 // End boundary is now by default
3679 - $date_to = date('Y-m-d H:i:s', $wp_now_ts);
4050 + $date_to = gmdate('Y-m-d H:i:s', $wp_now_ts);
3680 4051
3681 4052 if ($date_range === 'today') {
3682 4053 $start_ts = strtotime('today', $wp_now_ts);
3683 - $date_from = date('Y-m-d H:i:s', $start_ts);
4054 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3684 4055 } elseif ($date_range === 'yesterday') {
3685 4056 $start_ts = strtotime('yesterday', $wp_now_ts);
3686 4057 $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);
4058 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
4059 + $date_to = gmdate('Y-m-d H:i:s', $end_ts);
3689 4060 } elseif ($date_range === 'this_week') {
3690 4061 $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
4062 + $day_of_week = (int) gmdate('w', $wp_now_ts); // 0=Sun..6=Sat
3692 4063 // Convert start_of_week to PHP's 0..6 where 0=Sunday
3693 4064 $delta_days = ($day_of_week - $start_of_week + 7) % 7;
3694 4065 $start_ts = strtotime('-' . $delta_days . ' days', strtotime('today', $wp_now_ts));
3695 - $date_from = date('Y-m-d H:i:s', $start_ts);
4066 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3696 4067 } 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);
4068 + $start_ts = strtotime(gmdate('Y-m-01 00:00:00', $wp_now_ts));
4069 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3699 4070 } elseif ($date_range === 'all') {
3700 4071 // no bounds
3701 4072 }
3702 4073 }
@@ -3723,12 +4094,24 @@
3723 4094 ?>
3724 4095 <div class="dashboard-card">
3725 4096 <div class="sync-log-header">
3726 4097 <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>
4098 + <?php
4099 + // The page title and description are already rendered by
4100 + // render_layout_open() above. Repeating them here printed
4101 + // "Changes Log" twice on screen, so the card keeps only the
4102 + // retention hint — the one piece of copy the layout header
4103 + // does not carry.
4104 + ?>
4105 + <p style="color: var(--dashboard-text-secondary); margin-top: 0; margin-bottom: 0;">
4106 + <?php
4107 + // No opacity here: this used to be a de-emphasised
4108 + // aside trailing a full-contrast sentence, but it is
4109 + // now the card's only copy. At 12px/.75 it composites
4110 + // to 4.28:1 on the card background, under the 4.5:1
4111 + // AA floor; at full strength it reads 6.52:1.
4112 + ?>
4113 + <span style="font-size:13px;">Records are automatically removed after 90 days.</span>
3731 4114 </p>
3732 4115 </div>
3733 4116
3734 4117 <!-- Filters + Clear Log button - Right aligned -->
@@ -3738,9 +4121,9 @@
3738 4121 data-nonce="<?php echo esc_attr(wp_create_nonce('metasync_clear_sync_log')); ?>">
3739 4122 🗑 Clear Log
3740 4123 </button>
3741 4124 <form method="get" class="sync-filters-form" onchange="this.submit()" style="display:flex;flex-direction:row;align-items:center;gap:12px;flex-wrap:nowrap;">
3742 - <input type="hidden" name="page" value="<?php echo esc_attr($_GET['page']); ?>">
4125 + <input type="hidden" name="page" value="<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>">
3743 4126
3744 4127 <select name="date_range" class="sync-filter-select">
3745 4128 <option value="all" <?php selected($filters['date_range'] ?? 'all', 'all'); ?>> All Time</option>
3746 4129 <option value="today" <?php selected($filters['date_range'] ?? '', 'today'); ?>>Today</option>
@@ -3762,9 +4145,9 @@
3762 4145 <div class="sync-log-list">
3763 4146 <?php if (empty($sync_records)): ?>
3764 4147 <div class="sync-log-empty">
3765 4148 <div class="sync-log-empty-icon"><span class="dashicons dashicons-media-default" style="font-size:48px;width:48px;height:48px;color:var(--dashboard-text-secondary);"></span></div>
3766 - <h3>No sync records found</h3>
4149 + <h2>No sync records found</h2>
3767 4150 <p>Sync records will appear here when content/pages receive new updates.</p>
3768 4151 </div>
3769 4152 <?php else: ?>
3770 4153 <?php foreach ($sync_records as $record): ?>
@@ -3818,30 +4201,32 @@
3818 4201 <?php endif; ?>
3819 4202 </div>
3820 4203
3821 4204 <!-- 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>
4205 + <div class="sync-log-pagination">
4206 + <div class="sync-log-pagination-info">
4207 + Total records: <?php echo intval( $total_records ); ?><?php if ($total_records > 0): ?> | Showing <?php echo intval( $offset ) + 1; ?>-<?php echo intval( min($offset + $per_page, $total_records) ); ?><?php endif; ?>
4208 + <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper returns a complete escaped selector element.
4209 + echo Metasync_Per_Page_Helper::render_selector('sync_log', absint($per_page)); ?>
4210 + </div>
3827 4211
4212 + <?php if ($total_pages > 1): ?>
3828 4213 <div class="sync-log-pagination-controls">
3829 4214 <?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>
4215 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $page ) - 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">‹</a>
3831 4216 <?php endif; ?>
3832 4217
3833 4218 <?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) ); ?>"
4219 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $i ); ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>"
3835 4220 class="sync-pagination-btn <?php echo $i === $page ? 'active' : ''; ?>"><?php echo intval( $i ); ?></a>
3836 4221 <?php endfor; ?>
3837 4222
3838 4223 <?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>
4224 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $page ) + 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">›</a>
3840 4225 <?php endif; ?>
3841 4226 </div>
3842 - </div>
3843 - <?php endif; ?>
4227 + <?php endif; ?>
4228 + </div>
3844 4229 </div>
3845 4230 <?php $this->render_layout_close(); ?>
3846 4231
3847 4232 <script>
@@ -3922,8 +4307,19 @@
3922 4307 if (!empty($value)) {
3923 4308 $query_parts[] = $key . '=' . urlencode($value);
3924 4309 }
3925 4310 }
4311 +
4312 + // Preserve the user-selected results-per-page value across page
4313 + // navigation so a non-default page size survives clicking a page link.
4314 + $per_page_key = Metasync_Per_Page_Helper::request_key('sync_log');
4315 + if (isset($_GET[$per_page_key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4316 + $per_page = (int) $_GET[$per_page_key]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4317 + if (in_array($per_page, Metasync_Per_Page_Helper::allowed_values(), true)) {
4318 + $query_parts[] = $per_page_key . '=' . $per_page;
4319 + }
4320 + }
4321 +
3926 4322 return !empty($query_parts) ? '&' . implode('&', $query_parts) : '';
3927 4323 }
3928 4324
3929 4325 /**
@@ -4097,18 +4493,8 @@
4097 4493 {
4098 4494 Metasync_Connect_Manager::instance()->handle_session_management_early();
4099 4495 }
4100 4496
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 4497 private function handle_whitelabel_session_logic()
4112 4498 {
4113 4499 Metasync_Connect_Manager::instance()->handle_whitelabel_session_logic();
4114 4500 }
@@ -4154,8 +4540,9 @@
4154 4540 $stats = Metasync_CPU_Monitor::get_stats();
4155 4541 $per_core_threshold = Metasync_CPU_Monitor::get_per_core_threshold();
4156 4542 $cores = Metasync_CPU_Monitor::get_cpu_core_count();
4157 4543 $effective_threshold = Metasync_CPU_Monitor::get_effective_threshold();
4544 + $detection_reliable = Metasync_CPU_Monitor::is_core_detection_reliable();
4158 4545 ?>
4159 4546 <div style="background: var(--dashboard-card-bg); padding: 20px; border-radius: 8px;">
4160 4547 <!-- CPU Cores Detected -->
4161 4548 <div style="margin-bottom: 24px;">
@@ -4162,12 +4549,20 @@
4162 4549 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4163 4550 CPU Cores Detected
4164 4551 </label>
4165 4552 <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' : ''; ?>
4553 + <?php if ($detection_reliable) : ?>
4554 + <strong><?php echo intval($cores); ?></strong> core<?php echo $cores !== 1 ? 's' : ''; ?>
4555 + <?php else : ?>
4556 + <strong>Not detected</strong>
4557 + <?php endif; ?>
4167 4558 </div>
4168 4559 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
4169 - Automatically detected on this system.
4560 + <?php if ($detection_reliable) : ?>
4561 + Automatically detected on this system.
4562 + <?php else : ?>
4563 + Core detection is not available on this hosting environment.
4564 + <?php endif; ?>
4170 4565 </p>
4171 4566 </div>
4172 4567
4173 4568 <!-- Per-Core Load Threshold -->
@@ -4173,8 +4568,9 @@
4173 4568 <!-- Per-Core Load Threshold -->
4174 4569 <div style="margin-bottom: 24px;">
4175 4570 <label for="cpu_load_per_core_threshold" style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4176 4571 Per-Core Load Threshold
4572 + <?php Metasync::render_tooltip_icon('cpu_per_core_load_threshold', 'How busy each CPU core can get before the plugin pauses background SEO syncing. Lower = more cautious. Most sites leave this at 2.0.'); ?>
4177 4573 </label>
4178 4574 <input type="number"
4179 4575 id="cpu_load_per_core_threshold"
4180 4576 name="metasync_options[performance][cpu_load_per_core_threshold]"
@@ -4194,9 +4590,9 @@
4194 4590 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4195 4591 Effective Threshold
4196 4592 </label>
4197 4593 <div style="padding: 10px 12px; background: var(--dashboard-input-bg); border: 1px solid var(--dashboard-border); border-radius: 6px; color: var(--dashboard-text-secondary);">
4198 - <strong id="effective_threshold_value"><?php echo round($effective_threshold, 2); ?></strong>
4594 + <strong id="effective_threshold_value"><?php echo esc_html((string) round($effective_threshold, 2)); ?></strong>
4199 4595 </div>
4200 4596 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
4201 4597 Calculated as: cores × per-core threshold
4202 4598 </p>
@@ -4214,13 +4610,13 @@
4214 4610 <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo intval($stats['deferrals']); ?></div>
4215 4611 </div>
4216 4612 <div>
4217 4613 <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>
4614 + <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo esc_html((string) round($stats['max_load'], 2)); ?></div>
4219 4615 </div>
4220 4616 <div>
4221 4617 <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>
4618 + <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo esc_html((string) round($stats['avg_load'], 2)); ?></div>
4223 4619 </div>
4224 4620 </div>
4225 4621 </div>
4226 4622
@@ -4726,88 +5122,8 @@
4726 5122
4727 5123 /**
4728 5124 * Get the settings option array and print one of its values
4729 5125 */
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 5126 public function common_robot_meta_tags_callback()
4811 5127 {
4812 5128 Metasync_Settings_Fields::instance()->common_robot_meta_tags_callback();
4813 5129 }
@@ -5005,8 +5321,14 @@
5005 5321 * Show a one-time admin notice when a page builder is detected but the
5006 5322 * "Default Page Builder" setting has never been explicitly saved.
5007 5323 */
5008 5324 public function display_page_builder_notice() {
5325 + // Only relevant on the plugin's own settings page — avoid repeating
5326 + // this notice across every admin screen.
5327 + if (!isset($_GET['page']) || wp_unslash($_GET['page']) !== self::$page_slug) {
5328 + return;
5329 + }
5330 +
5009 5331 $configured = Metasync::get_option('general')['default_page_builder'] ?? '';
5010 5332
5011 5333 // Setting already saved — nothing to warn about
5012 5334 if (!empty($configured)) {
@@ -5019,9 +5341,9 @@
5019 5341 return;
5020 5342 }
5021 5343
5022 5344 // Handle dismiss action
5023 - if (isset($_GET['metasync_dismiss_builder_notice']) && wp_verify_nonce($_GET['_wpnonce'] ?? '', 'metasync_dismiss_builder')) {
5345 + if (isset($_GET['metasync_dismiss_builder_notice'], $_GET['_wpnonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'metasync_dismiss_builder')) {
5024 5346 update_user_meta(get_current_user_id(), 'metasync_builder_notice_dismissed', '1');
5025 5347 return;
5026 5348 }
5027 5349
@@ -5116,9 +5438,10 @@
5116 5438 * Saves the CPU load threshold and returns statistics
5117 5439 */
5118 5440 public function ajax_save_performance_settings() {
5119 5441 # 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')) {
5442 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5443 + if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce(wp_unslash($_POST['meta_sync_nonce']), 'meta_sync_general_setting_nonce')) {
5121 5444 wp_send_json_error(array('message' => 'Invalid nonce'));
5122 5445 return;
5123 5446 }
5124 5447
@@ -5186,9 +5509,9 @@
5186 5509 if (!wp_next_scheduled('metasync_cleanup_transients')) {
5187 5510 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_cleanup_transients');
5188 5511
5189 5512 if (!$scheduled) {
5190 - error_log('MetaSync: Failed to schedule transient cleanup cron job');
5513 + error_log('MetaSync: Failed to schedule transient cleanup cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5191 5514 }
5192 5515 }
5193 5516 }
5194 5517
@@ -5199,9 +5522,9 @@
5199 5522 {
5200 5523 $timestamp = wp_next_scheduled('metasync_cleanup_transients');
5201 5524 if ($timestamp) {
5202 5525 wp_unschedule_event($timestamp, 'metasync_cleanup_transients');
5203 - error_log('MetaSync: Transient cleanup cron job unscheduled');
5526 + error_log('MetaSync: Transient cleanup cron job unscheduled'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- unreachable in production
5204 5527 }
5205 5528 }
5206 5529
5207 5530 /**
@@ -5223,12 +5546,10 @@
5223 5546 {
5224 5547 if (!wp_next_scheduled('metasync_hidden_post_check')) {
5225 5548 $scheduled = wp_schedule_event(time(), 'metasync_weekly', 'metasync_hidden_post_check');
5226 5549
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');
5550 + if (!$scheduled) {
5551 + error_log('MetaSync: Failed to schedule hidden post manager cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5231 5552 }
5232 5553 }
5233 5554 }
5234 5555
@@ -5239,12 +5560,10 @@
5239 5560 public function maybe_schedule_otto_recheck_404_cron()
5240 5561 {
5241 5562 if (!wp_next_scheduled('metasync_otto_recheck_404_exclusions')) {
5242 5563 $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');
5564 + if (!$scheduled) {
5565 + error_log('MetaSync: Failed to schedule OTTO 404 recheck cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5247 5566 }
5248 5567 }
5249 5568 }
5250 5569
@@ -5317,13 +5636,13 @@
5317 5636
5318 5637 try {
5319 5638 // 1. Post revisions
5320 5639 if (!empty($settings['clean_post_revisions'])) {
5321 - $stats['post_revisions'] = (int) $wpdb->query(
5640 + $stats['post_revisions'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5322 5641 "DELETE FROM {$wpdb->posts} WHERE post_type = 'revision'"
5323 5642 );
5324 5643 // Remove postmeta left behind by deleted revisions
5325 - $wpdb->query(
5644 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5326 5645 "DELETE pm FROM {$wpdb->postmeta} pm
5327 5646 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5328 5647 WHERE p.ID IS NULL"
5329 5648 );
@@ -5331,20 +5650,19 @@
5331 5650
5332 5651 // 2. Trashed posts + their postmeta
5333 5652 if (!empty($settings['clean_trashed_posts'])) {
5334 5653 // Collect IDs first to cleanly remove postmeta
5335 - $trashed_ids = $wpdb->get_col(
5654 + $trashed_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5336 5655 "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'trash'"
5337 5656 );
5338 5657 if (!empty($trashed_ids)) {
5339 - $placeholders = implode(',', array_fill(0, count($trashed_ids), '%d'));
5340 - $wpdb->query(
5658 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5341 5659 $wpdb->prepare(
5342 - "DELETE FROM {$wpdb->postmeta} WHERE post_id IN ($placeholders)",
5660 + 'DELETE FROM ' . $wpdb->postmeta . ' WHERE post_id IN (' . implode(',', array_fill(0, count($trashed_ids), '%d')) . ')',
5343 5661 $trashed_ids
5344 5662 )
5345 5663 );
5346 - $stats['trashed_posts'] = (int) $wpdb->query(
5664 + $stats['trashed_posts'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5347 5665 "DELETE FROM {$wpdb->posts} WHERE post_status = 'trash'"
5348 5666 );
5349 5667 } else {
5350 5668 $stats['trashed_posts'] = 0;
@@ -5352,9 +5670,9 @@
5352 5670 }
5353 5671
5354 5672 // 3. Trashed comments
5355 5673 if (!empty($settings['clean_trashed_comments'])) {
5356 - $stats['trashed_comments'] = (int) $wpdb->query(
5674 + $stats['trashed_comments'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5357 5675 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'trash'"
5358 5676 );
5359 5677 }
5360 5678
@@ -5359,9 +5677,9 @@
5359 5677 }
5360 5678
5361 5679 // 4. Spam comments
5362 5680 if (!empty($settings['clean_spam_comments'])) {
5363 - $stats['spam_comments'] = (int) $wpdb->query(
5681 + $stats['spam_comments'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5364 5682 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'spam'"
5365 5683 );
5366 5684 }
5367 5685
@@ -5367,15 +5685,15 @@
5367 5685
5368 5686 // 5. Expired transients — direct SQL, no cache flush
5369 5687 if (!empty($settings['clean_expired_transients'])) {
5370 5688 // Delete timeout rows that have already expired
5371 - $wpdb->query(
5689 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5372 5690 "DELETE FROM {$wpdb->options}
5373 5691 WHERE option_name LIKE '\_transient\_timeout\_%'
5374 5692 AND option_value + 0 < UNIX_TIMESTAMP()"
5375 5693 );
5376 5694 // Delete value rows whose timeout row no longer exists
5377 - $stats['expired_transients'] = (int) $wpdb->query(
5695 + $stats['expired_transients'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5378 5696 "DELETE o FROM {$wpdb->options} o
5379 5697 LEFT JOIN {$wpdb->options} t
5380 5698 ON t.option_name = CONCAT('_transient_timeout_', SUBSTRING(o.option_name, 12))
5381 5699 WHERE o.option_name LIKE '\_transient\_%'
@@ -5385,9 +5703,9 @@
5385 5703 }
5386 5704
5387 5705 // 6. Orphaned postmeta (post_id references a post that no longer exists)
5388 5706 if (!empty($settings['clean_orphaned_postmeta'])) {
5389 - $stats['orphaned_postmeta'] = (int) $wpdb->query(
5707 + $stats['orphaned_postmeta'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5390 5708 "DELETE pm FROM {$wpdb->postmeta} pm
5391 5709 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5392 5710 WHERE p.ID IS NULL"
5393 5711 );
@@ -5399,12 +5717,10 @@
5399 5717 $settings['last_run_at'] = time();
5400 5718 $settings['last_run_stats'] = $stats;
5401 5719 update_option('metasync_db_cleanup_settings', $settings);
5402 5720
5403 - error_log('MetaSync: DB cleanup completed — ' . json_encode($stats));
5404 -
5405 5721 } catch (Exception $e) {
5406 - error_log('MetaSync: DB cleanup failed — ' . $e->getMessage());
5722 + error_log('MetaSync: DB cleanup failed — ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5407 5723 }
5408 5724 }
5409 5725
5410 5726 /**
@@ -5621,10 +5937,11 @@
5621 5937 /**
5622 5938 * AJAX: Save DB cleanup settings and reschedule cron accordingly.
5623 5939 */
5624 5940 public function ajax_save_db_cleanup_settings() {
5941 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5625 5942 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5626 - !wp_verify_nonce($_POST['db_cleanup_settings_nonce'], 'metasync_db_cleanup_settings_nonce')) {
5943 + !wp_verify_nonce(wp_unslash($_POST['db_cleanup_settings_nonce']), 'metasync_db_cleanup_settings_nonce')) {
5627 5944 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
5628 5945 return;
5629 5946 }
5630 5947
@@ -5681,10 +5998,11 @@
5681 5998 * AJAX: Manually trigger the DB cleanup and return stats for the UI.
5682 5999 * Persists current form state first so unsaved checkbox changes are respected.
5683 6000 */
5684 6001 public function ajax_run_db_cleanup() {
6002 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5685 6003 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5686 - !wp_verify_nonce($_POST['db_cleanup_settings_nonce'], 'metasync_db_cleanup_settings_nonce')) {
6004 + !wp_verify_nonce(wp_unslash($_POST['db_cleanup_settings_nonce']), 'metasync_db_cleanup_settings_nonce')) {
5687 6005 wp_send_json_error(array('message' => 'Invalid security token.'));
5688 6006 return;
5689 6007 }
5690 6008
@@ -5760,35 +6078,8 @@
5760 6078 ));
5761 6079 }
5762 6080
5763 6081 /**
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 6082 * AJAX handler to add excluded URL for OTTO
5792 6083 */
5793 6084 public function ajax_otto_add_excluded_url()
5794 6085 {
@@ -5934,8 +6225,9 @@
5934 6225 // If page has raw HTML or custom CSS from conversion, show badge
5935 6226 if ($has_raw_html || !empty($has_custom_css)) {
5936 6227 $label = $this->get_html_source_label();
5937 6228 $tooltip = sprintf(
6229 + /* translators: %s: HTML source label. */
5938 6230 __('This page was created using %s HTML-to-Builder converter', 'metasync'),
5939 6231 $label
5940 6232 );
5941 6233
@@ -5974,8 +6266,16 @@
5974 6266 if (!$post || !in_array($post->post_type, array('post', 'page'))) {
5975 6267 return;
5976 6268 }
5977 6269
6270 + // Don't show the "HTML-to-Builder converter" banner on LPS-synced / custom-HTML
6271 + // pages: those are raw-HTML store-and-serve pages, NOT actually converted to a
6272 + // page builder, so the banner mislabels them. It still shows for pages genuinely
6273 + // produced by the converter.
6274 + if (function_exists('metasync_is_custom_or_lps_page') && metasync_is_custom_or_lps_page($post->ID)) {
6275 + return;
6276 + }
6277 +
5978 6278 $has_raw_html = get_post_meta($post->ID, '_metasync_raw_html_enabled', true);
5979 6279 $has_custom_css = get_post_meta($post->ID, '_metasync_custom_css', true);
5980 6280
5981 6281 if ($has_raw_html || !empty($has_custom_css)) {
@@ -5980,8 +6280,9 @@
5980 6280
5981 6281 if ($has_raw_html || !empty($has_custom_css)) {
5982 6282 $label = $this->get_html_source_label();
5983 6283 $message = sprintf(
6284 + /* translators: %s: HTML source label. */
5984 6285 __('This page was created using %s HTML-to-Builder converter. The design is preserved with custom CSS and inline styles.', 'metasync'),
5985 6286 '<strong>' . esc_html($label) . '</strong>'
5986 6287 );
5987 6288
@@ -5995,9 +6296,9 @@
5995 6296 <p class="metasync-editor-message">%s</p>
5996 6297 </div>
5997 6298 </div>',
5998 6299 esc_html($label),
5999 - $message
6300 + wp_kses($message, array('strong' => array()))
6000 6301 );
6001 6302 }
6002 6303 }
6003 6304
@@ -6020,9 +6321,9 @@
6020 6321 ?>
6021 6322 <fieldset class="inline-edit-col-left metasync-quick-edit-source">
6022 6323 <div class="inline-edit-col">
6023 6324 <label>
6024 - <span class="title"><?php _e('Source', 'metasync'); ?></span>
6325 + <span class="title"><?php esc_html_e('Source', 'metasync'); ?></span>
6025 6326 <span class="metasync-quick-edit-badge-container"></span>
6026 6327 </label>
6027 6328 </div>
6028 6329 </fieldset>
@@ -6034,8 +6335,9 @@
6034 6335 */
6035 6336 public function add_html_pages_dashboard_widget()
6036 6337 {
6037 6338 $label = $this->get_html_source_label();
6339 + /* translators: %s: HTML source label. */
6038 6340 $widget_title = sprintf(__('%s Pages', 'metasync'), $label);
6039 6341
6040 6342 wp_add_dashboard_widget(
6041 6343 'metasync_html_pages_widget',
@@ -6176,34 +6478,8 @@
6176 6478 $bing_instant_index->send();
6177 6479 }
6178 6480
6179 6481 /**
6180 - * Save instant indexing settings (Google and Bing)
6181 - *
6182 - * @since 2.6.0
6183 - * @return void
6184 - */
6185 - public function save_instant_indexing_settings()
6186 - {
6187 - // Check if this is a settings submission
6188 - if (!isset($_POST['submit'])) {
6189 - return;
6190 - }
6191 -
6192 - // Save post types for Google Instant Indexing auto-submit
6193 - if (isset($_POST['metasync_post_types'])) {
6194 - $post_data = metasync_sanitize_input_array($_POST);
6195 - $post_types = is_array($post_data['metasync_post_types']) ? array_map('sanitize_title', $post_data['metasync_post_types']) : [];
6196 -
6197 - $settings = get_option('metasync_options_instant_indexing', ['post_types' => []]);
6198 - $settings['post_types'] = array_values($post_types);
6199 - update_option('metasync_options_instant_indexing', $settings);
6200 - }
6201 -
6202 - // Note: Bing Instant Indexing settings are saved via AJAX in save_bing_inline_settings_ajax()
6203 - }
6204 -
6205 - /**
6206 6482 * Save Bing instant indexing settings from inline form (Indexation Control page)
6207 6483 *
6208 6484 * @since 2.6.0
6209 6485 * @return bool True on success, false on failure
@@ -6273,12 +6549,24 @@
6273 6549 if (!empty($seo_controls['enable_googleinstantindex']) && $seo_controls['enable_googleinstantindex'] === 'true') {
6274 6550 $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
6275 6551 $post_types = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
6276 6552
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');
6553 + if (in_array($post->post_type, $post_types)) {
6554 + // save_post is the single owner of auto-submit and also fires
6555 + // on REST-created posts, where nothing else may have loaded
6556 + // the Google Index helpers yet — load them on demand instead
6557 + // of silently skipping.
6558 + if (!function_exists('google_index_direct')) {
6559 + $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
6560 + if (file_exists($google_index_path)) {
6561 + require_once $google_index_path;
6562 + }
6563 + }
6564 + if (function_exists('google_index_direct')) {
6565 + $service_info = google_index_direct()->get_service_account_info();
6566 + if (!isset($service_info['error'])) {
6567 + google_index_direct()->index_post($post_id, $post->post_type, 'update');
6568 + }
6281 6569 }
6282 6570 }
6283 6571 }
6284 6572