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 +724 -413 2.6.11 → 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.
@@ -198,13 +196,18 @@
198 196 $data = Metasync::get_option('general');
199 197
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 - if(!isset( $data['white_label_plugin_menu_slug'])){
203 - self::$page_slug = "searchatlas";
204 - }else{
205 - self::$page_slug = $data['white_label_plugin_menu_slug']=="" ? "searchatlas":$data['white_label_plugin_menu_slug'];
206 - }
200 + $raw_slug = isset($data['white_label_plugin_menu_slug']) ? $data['white_label_plugin_menu_slug'] : '';
201 + $clean_slug = sanitize_title($raw_slug);
202 + # Self-heal a legacy URL-shaped slug: persist the sanitized value so every
203 + # reader that builds admin links from the stored option gets a valid WP menu slug.
204 + if ($raw_slug !== '' && $clean_slug !== $raw_slug) {
205 + $options = Metasync::get_option();
206 + $options['general']['white_label_plugin_menu_slug'] = $clean_slug;
207 + Metasync::set_option($options);
208 + }
209 + self::$page_slug = $clean_slug === '' ? 'searchatlas' : $clean_slug;
207 210
208 211 add_action('admin_menu', array($this, 'add_plugin_settings_page'));
209 212 add_action('admin_menu', array($this, 'add_import_external_data_page'));
210 213 add_action('admin_init', array($this, 'settings_page_init'));
@@ -209,9 +212,8 @@
209 212 add_action('admin_menu', array($this, 'add_import_external_data_page'));
210 213 add_action('admin_init', array($this, 'settings_page_init'));
211 214 add_filter('all_plugins', array($this,'metasync_plugin_white_label'));
212 215 add_filter( 'plugin_row_meta',array($this,'metasync_view_detials_url'),10,3);
213 - add_filter('site_transient_update_plugins', array($this, 'inject_whitelabel_icon_into_update_transient'));
214 216
215 217 // Display transient error/success messages for redirections
216 218 add_action('admin_notices', array($this, 'display_redirection_messages'));
217 219
@@ -261,8 +263,15 @@
261 263
262 264 add_action('admin_init', array($this, 'initialize_cookie'));
263 265 add_action('admin_init', array($this, 'maybe_redirect_to_wizard'));
264 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 +
265 274 // Add admin_post hooks for form submissions (WordPress standard way - no output buffering needed)
266 275 add_action('admin_post_metasync_clear_all_cache_plugins', array($this, 'handle_clear_all_cache_plugins'));
267 276 add_action('admin_post_metasync_clear_otto_cache_all', array($this, 'handle_clear_otto_cache_all'));
268 277 add_action('admin_post_metasync_clear_otto_cache_url', array($this, 'handle_clear_otto_cache_url'));
@@ -279,8 +288,11 @@
279 288
280 289 // Add AJAX for saving execution settings
281 290 add_action( 'wp_ajax_metasync_save_execution_settings', array($this, 'ajax_save_execution_settings') );
282 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 +
283 295 // Add AJAX for saving hosting cache settings
284 296 add_action( 'wp_ajax_metasync_save_hosting_cache_settings', array($this, 'ajax_save_hosting_cache_settings') );
285 297
286 298 // Add AJAX for saving OTTO Cache TTL
@@ -310,8 +322,9 @@
310 322 add_action('wp_ajax_metasync_get_default_robots', array($this, 'ajax_get_default_robots'));
311 323 add_action('wp_ajax_metasync_preview_robots_backup', array($this, 'ajax_preview_robots_backup'));
312 324 add_action('wp_ajax_metasync_delete_robots_backup', array($this, 'ajax_delete_robots_backup'));
313 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'));
314 327
315 328 // Add AJAX handlers for host blocking test
316 329 add_action('wp_ajax_metasync_test_host_blocking_get', array($this, 'ajax_test_host_blocking_get'));
317 330 add_action('wp_ajax_metasync_test_host_blocking_post', array($this, 'ajax_test_host_blocking_post'));
@@ -365,10 +378,18 @@
365 378 add_action('wp_ajax_metasync_batch_progress', array($this, 'ajax_batch_progress'));
366 379 add_action('wp_ajax_metasync_bulk_optimize_selected', array($this, 'ajax_bulk_optimize_selected'));
367 380 add_action('wp_ajax_metasync_bulk_unoptimize_selected', array($this, 'ajax_bulk_unoptimize_selected'));
368 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'));
369 383 add_action('metasync_media_batch_optimize_cron', array($this, 'handle_media_batch_cron'));
370 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 +
371 392 # Add AJAX handlers for Google Instant Indexing
372 393 add_action('wp_ajax_metasync_send_giapi', array($this, 'ajax_send_giapi'));
373 394
374 395 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
@@ -373,11 +394,8 @@
373 394
374 395 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
375 396 add_action('wp_ajax_metasync_send_bing_indexnow', array($this, 'ajax_send_bing_indexnow'));
376 397
377 - # Add hooks for instant indexing settings saves
378 - add_action('admin_init', array($this, 'save_instant_indexing_settings'));
379 -
380 398 # Add hooks for instant indexing post actions
381 399 add_filter('post_row_actions', array($this, 'add_instant_indexing_post_actions'), 10, 2);
382 400 add_filter('page_row_actions', array($this, 'add_instant_indexing_post_actions'), 10, 2);
383 401
@@ -461,9 +479,11 @@
461 479
462 480 $data= Metasync::get_option('general');
463 481
464 482 # Get white label menu slug
465 - $menu_slug = empty($data['white_label_plugin_menu_slug']) ? self::$page_slug : $data['white_label_plugin_menu_slug'];
483 + # Sanitize so a URL-shaped legacy value still yields a valid WP menu slug
484 + $menu_slug = empty($data['white_label_plugin_menu_slug']) ? self::$page_slug : sanitize_title($data['white_label_plugin_menu_slug']);
485 + $menu_slug = $menu_slug === '' ? self::$page_slug : $menu_slug;
466 486
467 487 ?>
468 488 <style>
469 489 #toplevel_page_<?php echo esc_attr(str_replace(' ', '-',$menu_slug)); ?> .wp-menu-image.dashicons-before img {
@@ -480,23 +500,129 @@
480 500 <?php
481 501 }
482 502
483 503 public function metasync_fouc_prevention_style() {
484 - 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
485 505 return;
486 506 }
487 507 ?>
488 - <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>
489 524 <?php
490 525 }
491 526
492 527 public function suppress_notices_on_wizard_page() {
493 - 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 ) {
494 536 remove_all_actions( 'admin_notices' );
495 537 remove_all_actions( 'all_admin_notices' );
538 + return;
496 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 + }
497 545 }
498 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 +
499 625 #---------fixes issue : #95 ----------
500 626 #This function is to redirect in case client changes slug on fresh install
501 627 #It is called by the add_option hook
502 628
@@ -522,9 +648,9 @@
522 648
523 649 #Redirect url to the new slug
524 650 $redirect_url = admin_url('admin.php?page=' . $current_slug);
525 651
526 - wp_redirect($redirect_url);
652 + wp_safe_redirect($redirect_url);
527 653 exit;
528 654 }
529 655 }
530 656
@@ -576,13 +702,11 @@
576 702 * @since 1.0.0
577 703 */
578 704 public function maybe_redirect_to_wizard() {
579 705 // Only redirect if wizard should be shown
580 - if (get_option('metasync_show_wizard') && !isset($_GET['page'])) {
581 - delete_option('metasync_show_wizard');
582 -
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
583 707 // Don't redirect during AJAX, cron, or bulk activation
584 - 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
585 709 return;
586 710 }
587 711
588 712 // Only redirect if user has access to the plugin
@@ -589,16 +713,66 @@
589 713 if (!Metasync::current_user_has_plugin_access()) {
590 714 return;
591 715 }
592 716
717 + delete_option('metasync_show_wizard');
593 718 wp_safe_redirect(admin_url('admin.php?page=' . self::$page_slug . '-setup-wizard'));
594 719 exit;
595 720 }
596 721 }
597 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 +
598 771 public function metasync_display_error_log() {
599 772 Metasync_Debug_Manager::instance()->metasync_display_error_log($this);
600 773 }
774 +
601 775 public function metasync_update_wp_config() {
602 776 Metasync_Debug_Manager::instance()->metasync_update_wp_config();
603 777 }
604 778
@@ -641,9 +815,9 @@
641 815 ]);
642 816 $_COOKIE['metasync_previous_slug'] = $new_slug;
643 817 // Redirect to the new slug
644 818 $redirect_url = admin_url('admin.php?page=' . $old_slug);
645 - wp_redirect($redirect_url);
819 + wp_safe_redirect($redirect_url);
646 820 exit;
647 821 }else{
648 822 self::$page_slug = Metasync::get_option('general')['white_label_plugin_menu_slug']=="" ? "searchatlas":Metasync::get_option('general')['white_label_plugin_menu_slug'];
649 823 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
@@ -649,9 +823,9 @@
649 823 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
650 824
651 825 #add redirection for when the old slug is not defined
652 826 #this fixes the redirect issue #
653 - wp_redirect($redirect_url);
827 + wp_safe_redirect($redirect_url);
654 828 exit;
655 829 }
656 830 }
657 831 public function metasync_view_detials_url( $plugin_meta, $plugin_file, $plugin_data ) {
@@ -662,9 +836,10 @@
662 836 if (strpos($meta, 'open-plugin-details-modal') !== false) {
663 837 $meta = sprintf(
664 838 '<a href="%s" class="thickbox open-plugin-details-modal" aria-label="%s" data-title="%s">%s</a>',
665 839 add_query_arg('TB_iframe', 'true', $plugin_uri),
666 - 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'])),
667 842 esc_attr($plugin_data['Name']),
668 843 esc_html__('View details', 'metasync')
669 844 );
670 845 break; // Exit loop after replacing the link
@@ -712,53 +887,8 @@
712 887 return $all_plugins;
713 888 }
714 889
715 890 /**
716 - * Inject the whitelabel icon into the update_plugins transient so that
717 - * /wp-admin/update-core.php (Dashboard → Updates) shows the WL icon
718 - * instead of the SearchAtlas icon returned by the update API.
719 - *
720 - * @param object $transient The site transient object.
721 - * @return object
722 - */
723 - public function inject_whitelabel_icon_into_update_transient($transient) {
724 - if (empty($transient) || !is_object($transient)) {
725 - return $transient;
726 - }
727 -
728 - $general = Metasync::get_option('general');
729 - if (!is_array($general)) {
730 - return $transient;
731 - }
732 -
733 - $icon_url = $general['white_label_plugin_menu_icon'] ?? '';
734 - if (empty($icon_url)) {
735 - return $transient;
736 - }
737 -
738 - $this_plugin = plugin_basename(dirname(__DIR__) . '/metasync.php');
739 -
740 - // Inject into pending updates list
741 - if (!empty($transient->response) && isset($transient->response[$this_plugin])) {
742 - $transient->response[$this_plugin]->icons = [
743 - '1x' => $icon_url,
744 - '2x' => $icon_url,
745 - ];
746 - }
747 -
748 - // Also inject into the "no update needed" list so the icon appears
749 - // on the updates screen even when the plugin is up-to-date
750 - if (!empty($transient->no_update) && isset($transient->no_update[$this_plugin])) {
751 - $transient->no_update[$this_plugin]->icons = [
752 - '1x' => $icon_url,
753 - '2x' => $icon_url,
754 - ];
755 - }
756 -
757 - return $transient;
758 - }
759 -
760 - /**
761 891 * Register the stylesheets for the admin area.
762 892 *
763 893 * @since 1.0.0
764 894 */
@@ -763,9 +893,9 @@
763 893 * @since 1.0.0
764 894 */
765 895 public function enqueue_styles()
766 896 {
767 - $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
768 898
769 899 if (strpos($current_page, self::$page_slug) === 0) {
770 900 wp_enqueue_style(
771 901 $this->plugin_name,
@@ -793,10 +923,11 @@
793 923 'all'
794 924 );
795 925 }
796 926
797 - // Enqueue wizard CSS if on wizard page
798 - 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
799 930 wp_enqueue_style(
800 931 $this->plugin_name . '-setup-wizard',
801 932 plugin_dir_url(__FILE__) . 'css/metasync-setup-wizard.css',
802 933 array($this->plugin_name . '-dashboard'),
@@ -805,9 +936,9 @@
805 936 );
806 937 }
807 938
808 939 // Enqueue SEO Health CSS if on the SEO Health page
809 - 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
810 941 wp_enqueue_style(
811 942 $this->plugin_name . '-seo-health',
812 943 plugin_dir_url(__FILE__) . 'css/metasync-seo-health.css',
813 944 array($this->plugin_name . '-dashboard'),
@@ -824,9 +955,9 @@
824 955 */
825 956 public function enqueue_scripts()
826 957 {
827 958 // --- Phase 5 (#887): Extracted inline JS files ---
828 - $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
829 960 $plugin_root_url = plugin_dir_url(dirname(__FILE__));
830 961 $is_metasync_page = ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0);
831 962
832 963 if ($is_metasync_page) {
@@ -856,10 +987,49 @@
856 987 array('jquery'),
857 988 $this->version,
858 989 true
859 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 + );
860 1002 }
861 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 +
862 1032 // Dashboard iframe height (only on dashboard page)
863 1033 if ($current_page === self::$page_slug . '-dashboard' || $current_page === self::$page_slug) {
864 1034 wp_enqueue_script(
865 1035 $this->plugin_name . '-iframe',
@@ -908,12 +1078,12 @@
908 1078 true
909 1079 );
910 1080 // Pass the active tab from server (handles POST redirect)
911 1081 $sitemap_active_tab = 'general';
912 - if (isset($_GET['tab']) && in_array($_GET['tab'], ['general', 'news', 'video'], true)) {
913 - $sitemap_active_tab = sanitize_text_field(wp_unslash($_GET['tab']));
914 - } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) {
915 - $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
916 1086 }
917 1087 wp_localize_script($this->plugin_name . '-sitemap-tabs', 'metasyncSitemapTabs', [
918 1088 'activeTab' => $sitemap_active_tab,
919 1089 ]);
@@ -1044,9 +1214,8 @@
1044 1214 true
1045 1215 );
1046 1216 wp_localize_script($this->plugin_name . '-connect', 'metasyncConnectData', array(
1047 1217 'optionKey' => self::option_key,
1048 - 'storedPassword' => isset($whitelabel_data['settings_password']) ? $whitelabel_data['settings_password'] : '',
1049 1218 'adminPostUrl' => admin_url('admin-post.php'),
1050 1219 'exportNonce' => wp_create_nonce('metasync_export_whitelabel'),
1051 1220 'ajaxUrl' => admin_url('admin-ajax.php'),
1052 1221 'logoutNonceField' => wp_nonce_field('whitelabel_logout_nonce', 'whitelabel_logout_nonce', true, false),
@@ -1134,8 +1303,11 @@
1134 1303 array('jquery'),
1135 1304 $this->version,
1136 1305 true
1137 1306 );
1307 + wp_localize_script($this->plugin_name . '-bing-console', 'metasyncBingConsoleData', array(
1308 + 'nonce' => wp_create_nonce('metasync_nonce'),
1309 + ));
1138 1310 }
1139 1311
1140 1312 // Add redirection form
1141 1313 if ($current_page === self::$page_slug . '-redirections') {
@@ -1239,8 +1411,10 @@
1239 1411 'nonce' => wp_create_nonce('metasync_nonce'),
1240 1412 'sa_connect_nonce' => $sa_connect_nonce,
1241 1413 'reset_auth_nonce' => wp_create_nonce('metasync_reset_auth_nonce'),
1242 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'),
1243 1417 'heartbeat_state' => $heartbeat_state,
1244 1418 'dashboard_domain' => self::get_effective_dashboard_domain(),
1245 1419 'support_email' => Metasync::SUPPORT_EMAIL,
1246 1420 'documentation_domain' => Metasync::DOCUMENTATION_DOMAIN,
@@ -1304,9 +1478,9 @@
1304 1478 add_action('admin_notices', array($this, 'display_page_builder_notice'));
1305 1479 // Display update warning banner if plugin update is available
1306 1480 add_action('admin_notices', array($this, 'display_update_warning_banner'));
1307 1481 // Enqueue wizard assets if on wizard page
1308 - 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
1309 1483 wp_enqueue_script(
1310 1484 $this->plugin_name . '-setup-wizard',
1311 1485 plugin_dir_url(__FILE__) . 'js/metasync-setup-wizard.js',
1312 1486 array('jquery'),
@@ -1330,14 +1504,44 @@
1330 1504 }
1331 1505
1332 1506 /**
1333 1507 * Settings of HeartBeat API for admin area.
1334 - * 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.
1335 1526 */
1336 1527 function metasync_heartbeat_settings($settings)
1337 1528 {
1338 - global $heartbeat_frequency;
1339 - $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;
1340 1544 return $settings;
1341 1545 }
1342 1546
1343 1547 /**
@@ -1475,13 +1679,8 @@
1475 1679 {
1476 1680 Metasync_Connect_Manager::instance()->test_searchatlas_ajax_endpoint();
1477 1681 }
1478 1682
1479 - public function simple_ajax_test()
1480 - {
1481 - Metasync_Connect_Manager::instance()->simple_ajax_test();
1482 - }
1483 -
1484 1683 /**
1485 1684 * Create Admin Dashboard Iframe Page
1486 1685 * Embeds the Search Atlas dashboard directly in WordPress admin
1487 1686 */
@@ -1507,12 +1706,12 @@
1507 1706 $cache_count = Metasync_Otto_Transient_Cache::get_cache_count();
1508 1707
1509 1708 ?>
1510 1709 <!-- OTTO Cache TTL Setting -->
1511 - <div style="margin-bottom: 30px;">
1512 - <h4 style="margin-top: 0; color: var(--dashboard-text-primary);">OTTO Cache TTL</h4>
1710 + <div style="margin-bottom: 30px; padding-top: 20px;">
1711 + <h4 style="margin-top: 0; color: var(--dashboard-text-primary);"><?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> Cache TTL</h4>
1513 1712 <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">
1514 - 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.
1515 1714 </p>
1516 1715
1517 1716 <div style="background: rgba(255,255,255,0.02); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
1518 1717 <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
@@ -1518,8 +1717,9 @@
1518 1717 <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
1519 1718 <label for="metasync-otto-cache-ttl" style="color: var(--dashboard-text-primary); font-weight: 500;">
1520 1719 Cache TTL (minutes):
1521 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.'); ?>
1522 1722 <input type="number"
1523 1723 id="metasync-otto-cache-ttl"
1524 1724 value="<?php echo esc_attr($this->get_otto_cache_ttl_minutes()); ?>"
1525 1725 min="30"
@@ -1540,9 +1740,9 @@
1540 1740 </button>
1541 1741 <span id="metasync-otto-ttl-save-msg" style="display: none; font-size: 13px;"></span>
1542 1742 </div>
1543 1743
1544 - <input type="hidden" id="metasync-otto-ttl-nonce" value="<?php echo wp_create_nonce('metasync_otto_cache_ttl_nonce'); ?>" />
1744 + <input type="hidden" id="metasync-otto-ttl-nonce" value="<?php echo esc_attr(wp_create_nonce('metasync_otto_cache_ttl_nonce')); ?>" />
1545 1745 </div>
1546 1746 </div>
1547 1747
1548 1748 <!-- Cache Plugin Management -->
@@ -1567,9 +1767,9 @@
1567 1767 } else {
1568 1768 echo '<p style="color: var(--dashboard-text-secondary);">ℹ️ No cache plugins detected.</p>';
1569 1769 }
1570 1770 } catch (Exception $e) {
1571 - 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
1572 1772 echo '<p style="color: var(--dashboard-error);">⚠️ An error occurred while retrieving cache plugin status.</p>';
1573 1773 }
1574 1774 } else {
1575 1775 echo '<p style="color: var(--dashboard-error);">⚠️ Cache Purge class not loaded.</p>';
@@ -1586,12 +1786,12 @@
1586 1786 </form>
1587 1787
1588 1788 <?php
1589 1789 // Display success/error messages
1590 - if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') {
1591 - $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0;
1592 - $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0;
1593 - $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
1594 1794
1595 1795 if ($cleared > 0) {
1596 1796 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1597 1797 echo '✅ <strong>Success!</strong> Cleared cache for ' . intval( $cleared ) . ' plugin(s)';
@@ -1611,10 +1811,10 @@
1611 1811 echo '</p></div>';
1612 1812 }
1613 1813 }
1614 1814
1615 - if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') {
1616 - $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
1617 1817 if (empty($message)) {
1618 1818 $message = 'An unknown error occurred while clearing cache. Please check error logs for details.';
1619 1819 }
1620 1820 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
@@ -1663,8 +1863,9 @@
1663 1863 <?php checked(true, !empty($hosting_settings['wpengine_enabled'])); ?>
1664 1864 <?php echo !$wpe_detected ? 'disabled' : ''; ?>
1665 1865 style="width: 16px; height: 16px; cursor: <?php echo $wpe_detected ? 'pointer' : 'not-allowed'; ?>;" />
1666 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.'); ?>
1667 1868 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges Varnish + Memcached</span>
1668 1869 </label>
1669 1870
1670 1871 <label style="display: flex; align-items: center; gap: 10px; margin-bottom: 16px; cursor: pointer;">
@@ -1673,8 +1874,9 @@
1673 1874 <?php checked(true, !empty($hosting_settings['kinsta_enabled'])); ?>
1674 1875 <?php echo !$kinsta_detected ? 'disabled' : ''; ?>
1675 1876 style="width: 16px; height: 16px; cursor: <?php echo $kinsta_detected ? 'pointer' : 'not-allowed'; ?>;" />
1676 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.'); ?>
1677 1879 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges full-page cache (kinsta_cache_purge_full)</span>
1678 1880 </label>
1679 1881
1680 1882 <div style="display: flex; align-items: center; gap: 12px;">
@@ -1688,9 +1890,9 @@
1688 1890 </button>
1689 1891 <span id="metasync-hc-save-msg" style="display: none; font-size: 13px;"></span>
1690 1892 </div>
1691 1893
1692 - <input type="hidden" id="metasync-hc-nonce" value="<?php echo wp_create_nonce('metasync_hosting_cache_nonce'); ?>" />
1894 + <input type="hidden" id="metasync-hc-nonce" value="<?php echo esc_attr(wp_create_nonce('metasync_hosting_cache_nonce')); ?>" />
1693 1895 </div>
1694 1896
1695 1897 <!-- Purge button -->
1696 1898 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
@@ -1709,12 +1911,13 @@
1709 1911 </form>
1710 1912
1711 1913 <?php
1712 1914 // Hosting cache result messages
1713 - if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') {
1714 - $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode($_GET['hc_cleared'])) : '';
1715 - $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode($_GET['hc_failed'])) : '';
1716 - $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
1717 1920
1718 1921 if ($hc_cleared) {
1719 1922 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1720 1923 echo '✅ <strong>Success!</strong> Purged hosting cache on: ' . esc_html($hc_cleared);
@@ -1729,8 +1932,9 @@
1729 1932 echo '<div class="notice notice-info inline" style="margin-top: 10px;"><p>';
1730 1933 echo 'ℹ️ No enabled hosting providers were detected on this server (' . esc_html($hc_not_detected) . ').';
1731 1934 echo '</p></div>';
1732 1935 }
1936 + // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1733 1937 }
1734 1938 ?>
1735 1939
1736 1940 <script>
@@ -1822,9 +2026,9 @@
1822 2026 id="metasync-targeted-object-cache"
1823 2027 <?php checked('1', $targeted_cache_enabled); ?>
1824 2028 style="width: 16px; height: 16px; cursor: pointer; margin-top: 2px; flex-shrink: 0;" />
1825 2029 <span>
1826 - <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>
1827 2031 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">
1828 2032 When enabled, only the updated posts are evicted from the object cache (recommended for large sites).
1829 2033 When disabled, a full <code>wp_cache_flush()</code> is used instead.
1830 2034 </span>
@@ -1895,11 +2099,11 @@
1895 2099 </div>
1896 2100
1897 2101 <?php
1898 2102 // Display success/error messages
1899 - if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') {
1900 - $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0;
1901 - $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
1902 2106
1903 2107 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1904 2108 if (!empty($url)) {
1905 2109 echo '✅ <strong>Success!</strong> Cleared cache for URL: <code>' . esc_html($url) . '</code> (' . intval( $cleared_count ) . ' entries)';
@@ -1908,10 +2112,10 @@
1908 2112 }
1909 2113 echo '</p></div>';
1910 2114 }
1911 2115
1912 - if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') {
1913 - $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
1914 2118 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
1915 2119 echo '❌ <strong>Error:</strong> ' . esc_html($message);
1916 2120 echo '</p></div>';
1917 2121 }
@@ -1950,9 +2154,9 @@
1950 2154 <td>
1951 2155 <input type="url"
1952 2156 id="otto_cache_url"
1953 2157 name="otto_cache_url"
1954 - 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 ?>"
1955 2159 class="regular-text"
1956 2160 placeholder="https://example.com/page/"
1957 2161 required />
1958 2162 <p class="description" style="color: var(--dashboard-text-secondary);">Enter the full URL of the page whose cache you want to clear.</p>
@@ -2023,9 +2227,10 @@
2023 2227 /**
2024 2228 * AJAX handler for saving hosting cache settings
2025 2229 */
2026 2230 public function ajax_save_hosting_cache_settings() {
2027 - 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')) {
2028 2233 wp_send_json_error(array('message' => 'Security check failed'));
2029 2234 return;
2030 2235 }
2031 2236
@@ -2046,9 +2251,10 @@
2046 2251 /**
2047 2252 * AJAX handler: save object cache behaviour settings
2048 2253 */
2049 2254 public function ajax_save_object_cache_settings() {
2050 - 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')) {
2051 2257 wp_send_json_error(array('message' => 'Security check failed'));
2052 2258 return;
2053 2259 }
2054 2260
@@ -2075,9 +2281,10 @@
2075 2281 /**
2076 2282 * AJAX handler for saving OTTO Cache TTL
2077 2283 */
2078 2284 public function ajax_save_otto_cache_ttl() {
2079 - 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')) {
2080 2287 wp_send_json_error(array('message' => 'Security check failed.'));
2081 2288 return;
2082 2289 }
2083 2290
@@ -2088,9 +2295,9 @@
2088 2295
2089 2296 $ttl = isset($_POST['otto_cache_ttl']) ? absint($_POST['otto_cache_ttl']) : 30;
2090 2297
2091 2298 if ($ttl < 30 || $ttl > 1440) {
2092 - 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())));
2093 2300 return;
2094 2301 }
2095 2302
2096 2303 $settings = get_option('metasync_execution_settings', array());
@@ -2096,9 +2303,9 @@
2096 2303 $settings = get_option('metasync_execution_settings', array());
2097 2304 $settings['otto_cache_ttl'] = $ttl;
2098 2305 update_option('metasync_execution_settings', $settings);
2099 2306
2100 - 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())));
2101 2308 }
2102 2309
2103 2310 /**
2104 2311 * admin_post handler: purge WP Engine and Kinsta hosting-level caches
@@ -2103,9 +2310,10 @@
2103 2310 /**
2104 2311 * admin_post handler: purge WP Engine and Kinsta hosting-level caches
2105 2312 */
2106 2313 public function handle_purge_hosting_cache() {
2107 - 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')) {
2108 2316 wp_die('Security check failed');
2109 2317 }
2110 2318
2111 2319 if (!Metasync::current_user_has_plugin_access()) {
@@ -2125,9 +2333,9 @@
2125 2333 WpeCommon::purge_varnish_cache();
2126 2334 WpeCommon::purge_memcached();
2127 2335 $cleared[] = 'WP Engine';
2128 2336 } catch (Exception $e) {
2129 - 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
2130 2338 $failed[] = 'WP Engine';
2131 2339 }
2132 2340 } else {
2133 2341 $not_detected[] = 'WP Engine';
@@ -2140,9 +2348,9 @@
2140 2348 try {
2141 2349 KinstaCache::get_instance()->kinsta_cache_purge_full();
2142 2350 $cleared[] = 'Kinsta';
2143 2351 } catch (Exception $e) {
2144 - 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
2145 2353 $failed[] = 'Kinsta';
2146 2354 }
2147 2355 } else {
2148 2356 $not_detected[] = 'Kinsta';
@@ -2537,9 +2745,9 @@
2537 2745 # Initialize MetaSync API request class and trigger synchronization
2538 2746 (new Metasync_Sync_Requests())->SyncCustomerParams();
2539 2747 } catch (Exception $e) {
2540 2748 # Log any API request errors for debugging
2541 - 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
2542 2750 }
2543 2751 }
2544 2752
2545 2753 /*
@@ -2554,9 +2762,9 @@
2554 2762 # Initialize MetaSync API request class and trigger synchronization
2555 2763 (new Metasync_Sync_Requests())->SyncCustomerParams();
2556 2764 } catch (Exception $e) {
2557 2765 # Log any API request errors for debugging
2558 - 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
2559 2767 }
2560 2768 }
2561 2769
2562 2770 /**
@@ -2589,8 +2797,15 @@
2589 2797 // Handle form submissions
2590 2798 if (isset($_POST['metasync_media_optimization_nonce'])) {
2591 2799 check_admin_referer('metasync_save_media_optimization', 'metasync_media_optimization_nonce');
2592 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 +
2593 2808 // Handle reset to defaults
2594 2809 if (!empty($_POST['metasync_media_reset'])) {
2595 2810 $defaults = Metasync_Media_Settings::get_defaults();
2596 2811 Metasync_Media_Settings::save_settings($defaults);
@@ -2595,8 +2810,9 @@
2595 2810 $defaults = Metasync_Media_Settings::get_defaults();
2596 2811 Metasync_Media_Settings::save_settings($defaults);
2597 2812 $save_success = true;
2598 2813 } elseif (isset($_POST['metasync_media'])) {
2814 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Media_Settings::save_settings().
2599 2815 $input = wp_unslash($_POST['metasync_media']);
2600 2816 Metasync_Media_Settings::save_settings($input);
2601 2817 $save_success = true;
2602 2818 }
@@ -2628,8 +2844,15 @@
2628 2844 * Code Minification page callback
2629 2845 */
2630 2846 public function create_admin_code_minification_page()
2631 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 +
2632 2855 $this->render_layout_open('Code Minification', 'code_minification', 'Minify CSS, JavaScript, and HTML to improve performance.');
2633 2856 // Load settings and compatibility classes
2634 2857 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-settings.php';
2635 2858 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-cache.php';
@@ -2646,8 +2869,9 @@
2646 2869 $defaults = Metasync_Minification_Settings::get_defaults();
2647 2870 Metasync_Minification_Settings::save_settings($defaults);
2648 2871 $save_success = true;
2649 2872 } elseif (isset($_POST['metasync_code_min'])) {
2873 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Minification_Settings::save_settings().
2650 2874 $input = (array) wp_unslash($_POST['metasync_code_min']);
2651 2875 Metasync_Minification_Settings::save_settings($input);
2652 2876 $save_success = true;
2653 2877 }
@@ -2680,8 +2904,15 @@
2680 2904 if (!$attachment_id) {
2681 2905 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2682 2906 }
2683 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 +
2684 2915 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-settings.php';
2685 2916 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-image-converter.php';
2686 2917 $settings = Metasync_Media_Settings::get_settings();
2687 2918
@@ -2695,8 +2926,9 @@
2695 2926 $max_bytes = 10 * 1024 * 1024;
2696 2927 if (filesize($file) > $max_bytes) {
2697 2928 $size_mb = round(filesize($file) / 1024 / 1024, 1);
2698 2929 wp_send_json_error(sprintf(
2930 + /* translators: %s: file size in megabytes. */
2699 2931 __('Optimization skipped: file size (%s MB) exceeds the 10 MB safety limit to prevent memory issues.', 'metasync'),
2700 2932 $size_mb
2701 2933 ));
2702 2934 }
@@ -2733,8 +2965,20 @@
2733 2965 if (!$attachment_id) {
2734 2966 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2735 2967 }
2736 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 +
2737 2981 $success = Metasync_Image_Converter::revert_attachment($attachment_id);
2738 2982
2739 2983 if ($success) {
2740 2984 wp_send_json_success();
@@ -2786,8 +3030,15 @@
2786 3030 public function ajax_batch_progress()
2787 3031 {
2788 3032 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
2789 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 +
2790 3041 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-batch-optimizer.php';
2791 3042 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
2792 3043
2793 3044 $progress = Metasync_Media_Batch_Optimizer::get_progress();
@@ -2818,10 +3069,19 @@
2818 3069 $settings = Metasync_Media_Settings::get_settings();
2819 3070
2820 3071 $success = 0;
2821 3072 $failed = 0;
3073 + $denied = 0;
2822 3074
2823 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 +
2824 3084 if (Metasync_Image_Converter::convert_attachment($id, $settings)) {
2825 3085 $success++;
2826 3086 } else {
2827 3087 $failed++;
@@ -2830,8 +3090,9 @@
2830 3090
2831 3091 wp_send_json_success([
2832 3092 'success' => $success,
2833 3093 'failed' => $failed,
3094 + 'denied' => $denied,
2834 3095 ]);
2835 3096 }
2836 3097
2837 3098 /**
@@ -2854,11 +3115,19 @@
2854 3115
2855 3116 $success = 0;
2856 3117 $failed = 0;
2857 3118 $skipped = 0;
3119 + $denied = 0;
2858 3120 $errors = [];
2859 3121
2860 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 +
2861 3130 $format = get_post_meta($id, '_metasync_converted_format', true);
2862 3131
2863 3132 if (!$format) {
2864 3133 $skipped++;
@@ -2868,8 +3137,9 @@
2868 3137 if (!Metasync_Image_Converter::can_revert($id)) {
2869 3138 $skipped++;
2870 3139 $file = get_attached_file($id);
2871 3140 $name = $file ? basename($file) : "ID {$id}";
3141 + /* translators: %s: attachment file name. */
2872 3142 $errors[] = sprintf(__('%s: skipped — original image unavailable.', 'metasync'), $name);
2873 3143 continue;
2874 3144 }
2875 3145
@@ -2878,8 +3148,9 @@
2878 3148 } else {
2879 3149 $failed++;
2880 3150 $file = get_attached_file($id);
2881 3151 $name = $file ? basename($file) : "ID {$id}";
3152 + /* translators: %s: attachment file name. */
2882 3153 $errors[] = sprintf(__('%s: revert failed (original file may not exist).', 'metasync'), $name);
2883 3154 }
2884 3155 }
2885 3156
@@ -2886,8 +3157,9 @@
2886 3157 wp_send_json_success([
2887 3158 'success' => $success,
2888 3159 'failed' => $failed,
2889 3160 'skipped' => $skipped,
3161 + 'denied' => $denied,
2890 3162 'errors' => $errors,
2891 3163 ]);
2892 3164 }
2893 3165
@@ -2911,8 +3183,49 @@
2911 3183 wp_send_json_success($progress);
2912 3184 }
2913 3185
2914 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 + /**
2915 3228 * Cron handler: Process batch optimization tick.
2916 3229 */
2917 3230 public function handle_media_batch_cron()
2918 3231 {
@@ -2922,8 +3235,86 @@
2922 3235 Metasync_Media_Batch_Optimizer::process_batch_tick();
2923 3236 }
2924 3237
2925 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 + /**
2926 3317 * Report Issue page callback
2927 3318 */
2928 3319 public function create_admin_report_issue_page()
2929 3320 {
@@ -2935,8 +3326,17 @@
2935 3326 * XML Sitemap page callback
2936 3327 */
2937 3328 public function create_admin_xml_sitemap_page()
2938 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 +
2939 3339 // Load sitemap generator class
2940 3340 require_once plugin_dir_path(dirname(__FILE__)) . 'sitemap/class-metasync-sitemap-generator.php';
2941 3341
2942 3342 $sitemap_generator = new Metasync_Sitemap_Generator();
@@ -2964,13 +3364,16 @@
2964 3364
2965 3365 update_option('metasync_sitemap_settings', $sitemap_settings);
2966 3366
2967 3367 // Regenerate sitemap with new content settings
2968 - $result = $sitemap_generator->generate_sitemap();
3368 + $result = $sitemap_generator->generate_sitemap( true );
2969 3369 if (is_wp_error($result)) {
2970 3370 echo '<div class="notice notice-error"><p>' . esc_html(
3371 + /* translators: %s: error message. */
2971 3372 sprintf(__('Settings saved but sitemap generation failed: %s', 'metasync'), $result->get_error_message())
2972 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>';
2973 3376 } else {
2974 3377 echo '<div class="notice notice-success"><p>' . esc_html__('Sitemap content settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
2975 3378 }
2976 3379 }
@@ -2981,10 +3384,10 @@
2981 3384
2982 3385 // Build generic taxonomy filters
2983 3386 $news_taxonomies = [];
2984 3387 if (!empty($_POST['news_taxonomies']) && is_array($_POST['news_taxonomies'])) {
2985 - foreach ($_POST['news_taxonomies'] as $tax_name => $term_ids) {
2986 - $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));
2987 3390 }
2988 3391 }
2989 3392
2990 3393 $news_settings = [
@@ -3009,9 +3412,9 @@
3009 3412 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3010 3413 } else {
3011 3414 // Also remove physical file if it exists
3012 3415 if (file_exists(ABSPATH . 'news-sitemap.xml')) {
3013 - @unlink(ABSPATH . 'news-sitemap.xml');
3416 + @wp_delete_file(ABSPATH . 'news-sitemap.xml');
3014 3417 }
3015 3418 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
3016 3419 }
3017 3420 }
@@ -3022,10 +3425,10 @@
3022 3425
3023 3426 // Build generic taxonomy filters
3024 3427 $video_taxonomies = [];
3025 3428 if (!empty($_POST['video_taxonomies']) && is_array($_POST['video_taxonomies'])) {
3026 - foreach ($_POST['video_taxonomies'] as $tax_name => $term_ids) {
3027 - $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));
3028 3431 }
3029 3432 }
3030 3433
3031 3434 $video_settings = [
@@ -3033,8 +3436,12 @@
3033 3436 'post_types' => array_map('sanitize_key', (array) ($_POST['video_post_types'] ?? ['post', 'page'])),
3034 3437 'auto_detect' => isset($_POST['auto_detect']),
3035 3438 'taxonomies' => $video_taxonomies,
3036 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'] ?? '')),
3037 3444 ];
3038 3445
3039 3446 // Always invalidate old cache before saving new settings
3040 3447 delete_transient('metasync_vsm_' . md5('video-sitemap.xml'));
@@ -3047,9 +3454,9 @@
3047 3454 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3048 3455 } else {
3049 3456 // Also remove physical file if it exists
3050 3457 if (file_exists(ABSPATH . 'video-sitemap.xml')) {
3051 - @unlink(ABSPATH . 'video-sitemap.xml');
3458 + @wp_delete_file(ABSPATH . 'video-sitemap.xml');
3052 3459 }
3053 3460 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
3054 3461 }
3055 3462 }
@@ -3092,8 +3499,17 @@
3092 3499 if ($sitemap_generator->generate_news_sitemap()) {
3093 3500 $extras[] = 'news';
3094 3501 }
3095 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 +
3096 3512 if (!empty($video_opts['enabled'])) {
3097 3513 if ($sitemap_generator->generate_video_sitemap()) {
3098 3514 $extras[] = 'video';
3099 3515 }
@@ -3098,21 +3514,30 @@
3098 3514 $extras[] = 'video';
3099 3515 }
3100 3516 }
3101 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 +
3102 3523 // Generate main sitemap (its index will include news/video since they now exist)
3103 - $result = $sitemap_generator->generate_sitemap();
3524 + $result = $sitemap_generator->generate_sitemap( true );
3104 3525
3105 3526 if (is_wp_error($result)) {
3106 3527 $error_msg = $result->get_error_message();
3107 - 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
3108 3529 echo '<div class="notice notice-error"><p>' . esc_html(
3530 + /* translators: %s: error message. */
3109 3531 sprintf(__('Sitemap generation failed: %s', 'metasync'), $error_msg)
3110 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>';
3111 3535 } else {
3112 3536 $message = esc_html__('Sitemap generated successfully!', 'metasync');
3113 3537 if (!empty($extras)) {
3114 3538 $message .= ' ' . sprintf(
3539 + /* translators: %s: list of sitemap types. */
3115 3540 esc_html__('Also generated %s sitemap(s).', 'metasync'),
3116 3541 implode(' & ', $extras)
3117 3542 );
3118 3543 }
@@ -3147,9 +3572,9 @@
3147 3572
3148 3573 if ($deleted) {
3149 3574 // Disable auto-update only when the general sitemap is removed
3150 3575 update_option('metasync_sitemap_auto_update', false);
3151 - // 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
3152 3577 if (!$sitemap_generator->sitemap_exists()) {
3153 3578 delete_option('metasync_disable_wp_sitemap');
3154 3579 }
3155 3580 echo '<div class="notice notice-success"><p>' . esc_html__('General sitemap deleted successfully!', 'metasync') . '</p></div>';
@@ -3178,9 +3603,9 @@
3178 3603
3179 3604 if ($deleted) {
3180 3605 // Also disable auto-update when deleting
3181 3606 update_option('metasync_sitemap_auto_update', false);
3182 - // 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
3183 3608 delete_option('metasync_disable_wp_sitemap');
3184 3609 echo '<div class="notice notice-success"><p>' . esc_html__('All sitemaps deleted successfully!', 'metasync') . '</p></div>';
3185 3610 } else {
3186 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>';
@@ -3301,47 +3726,8 @@
3301 3726 Metasync_Admin_Pages::get_instance($this)->create_admin_breadcrumbs_page();
3302 3727 }
3303 3728
3304 3729 /**
3305 - * Google Instant Index Setting page callback
3306 - */
3307 - public function create_admin_google_instant_index_page()
3308 - {
3309 - $this->render_layout_open('Instant Indexing', 'instant_index', 'Submit URLs to Google for instant indexing via the Indexing API.');
3310 -
3311 - // Render shared Google Index credentials section
3312 - if (!function_exists('google_index_direct')) {
3313 - if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) {
3314 - require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
3315 - } else {
3316 - error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php');
3317 - return;
3318 - }
3319 - }
3320 - $google_index = google_index_direct();
3321 - $service_info = $google_index->get_service_account_info();
3322 - $is_configured = !isset($service_info['error']);
3323 -
3324 - $saved_json_display = $is_configured ? $google_index->get_redacted_config_json() : '';
3325 -
3326 - include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-index-api-settings.php';
3327 -
3328 - // Render post types selection with save form
3329 - $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
3330 - $post_types_settings = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
3331 - ?>
3332 - <form method="POST" action="">
3333 - <?php include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-instant-post-types.php'; ?>
3334 - <div class="dashboard-card" style="padding: 20px;">
3335 - <?php submit_button('Save Post Types', 'primary', 'submit', false, array('class' => 'button button-primary')); ?>
3336 - </div>
3337 - </form>
3338 - <?php
3339 -
3340 - $this->render_layout_close();
3341 - }
3342 -
3343 - /**
3344 3730 * Google Console page callback
3345 3731 */
3346 3732 public function create_admin_google_console_page()
3347 3733 {
@@ -3364,16 +3750,8 @@
3364 3750 $this->render_layout_close();
3365 3751 }
3366 3752
3367 3753 /**
3368 - * General Options page callback
3369 - */
3370 - public function create_admin_optimal_settings_page()
3371 - {
3372 - Metasync_Admin_Pages::get_instance($this)->create_admin_optimal_settings_page();
3373 - }
3374 -
3375 - /**
3376 3754 * Global Options page callback
3377 3755 */
3378 3756 public function create_admin_global_settings_page()
3379 3757 {
@@ -3405,16 +3783,8 @@
3405 3783 Metasync_Admin_Pages::get_instance($this)->create_admin_seo_controls_page();
3406 3784 }
3407 3785
3408 3786 /**
3409 - * Site Optimal Settings page callback
3410 - */
3411 - public function optimization_settings_options()
3412 - {
3413 - Metasync_Admin_Pages::get_instance($this)->optimization_settings_options();
3414 - }
3415 -
3416 - /**
3417 3787 * redirection page callback with tabs
3418 3788 */
3419 3789 public function create_admin_redirections_page()
3420 3790 {
@@ -3441,10 +3811,11 @@
3441 3811 }
3442 3812 delete_transient( Metasync_CPU_Monitor::DEFER_NOTICE_TRANSIENT );
3443 3813 echo '<div class="notice notice-warning is-dismissible"><p>';
3444 3814 printf(
3445 - /* translators: 1: current load, 2: threshold, 3: core count */
3446 - 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() ),
3447 3818 esc_html( $data['load'] ),
3448 3819 esc_html( $data['threshold'] ),
3449 3820 esc_html( $data['cores'] )
3450 3821 );
@@ -3462,9 +3833,10 @@
3462 3833 if (!get_transient('metasync_llms_conflict')) {
3463 3834 return;
3464 3835 }
3465 3836 echo '<div class="notice notice-info is-dismissible"><p>';
3466 - 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()));
3467 3839 echo '</p></div>';
3468 3840 }
3469 3841
3470 3842 /**
@@ -3533,8 +3905,17 @@
3533 3905 public function ajax_restore_robots_backup()
3534 3906 {
3535 3907 Metasync_Admin_Ajax::instance()->ajax_restore_robots_backup();
3536 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 +
3537 3918 public function ajax_create_redirect_from_404()
3538 3919 {
3539 3920 Metasync_Admin_Ajax::instance()->ajax_create_redirect_from_404();
3540 3921 }
@@ -3562,9 +3943,9 @@
3562 3943 {
3563 3944 register_rest_route('metasync/v1', '/ping', array(
3564 3945 'methods' => array('GET', 'POST'),
3565 3946 'callback' => array($this, 'handle_ping_rest_endpoint'),
3566 - 'permission_callback' => '__return_true', // Allow public access
3947 + 'permission_callback' => '__return_true',
3567 3948 'args' => array(
3568 3949 'test' => array(
3569 3950 'description' => 'Optional test parameter',
3570 3951 'type' => 'string',
@@ -3646,17 +4027,17 @@
3646 4027 return;
3647 4028 }
3648 4029
3649 4030 // Get pagination parameters
3650 - $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
3651 - $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);
3652 4033 $offset = ($page - 1) * $per_page;
3653 4034
3654 4035 // Get filters
3655 4036 $filters = [
3656 4037 // UI exposes date_range and status only. We compute date_from/date_to based on date_range
3657 - 'date_range' => isset($_GET['date_range']) ? sanitize_text_field(wp_unslash($_GET['date_range'])) : '',
3658 - '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
3659 4040 ];
3660 4041
3661 4042 // Map date_range to concrete date_from/date_to for DB queries
3662 4043 $date_range = $filters['date_range'];
@@ -3665,28 +4046,28 @@
3665 4046 $date_to = '';
3666 4047
3667 4048 if (!empty($date_range)) {
3668 4049 // End boundary is now by default
3669 - $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);
3670 4051
3671 4052 if ($date_range === 'today') {
3672 4053 $start_ts = strtotime('today', $wp_now_ts);
3673 - $date_from = date('Y-m-d H:i:s', $start_ts);
4054 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3674 4055 } elseif ($date_range === 'yesterday') {
3675 4056 $start_ts = strtotime('yesterday', $wp_now_ts);
3676 4057 $end_ts = strtotime('today', $wp_now_ts) - 1; // end of yesterday
3677 - $date_from = date('Y-m-d H:i:s', $start_ts);
3678 - $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);
3679 4060 } elseif ($date_range === 'this_week') {
3680 4061 $start_of_week = (int) get_option('start_of_week', 1); // 0=Sun, 1=Mon
3681 - $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
3682 4063 // Convert start_of_week to PHP's 0..6 where 0=Sunday
3683 4064 $delta_days = ($day_of_week - $start_of_week + 7) % 7;
3684 4065 $start_ts = strtotime('-' . $delta_days . ' days', strtotime('today', $wp_now_ts));
3685 - $date_from = date('Y-m-d H:i:s', $start_ts);
4066 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3686 4067 } elseif ($date_range === 'this_month') {
3687 - $start_ts = strtotime(date('Y-m-01 00:00:00', $wp_now_ts));
3688 - $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);
3689 4070 } elseif ($date_range === 'all') {
3690 4071 // no bounds
3691 4072 }
3692 4073 }
@@ -3713,12 +4094,24 @@
3713 4094 ?>
3714 4095 <div class="dashboard-card">
3715 4096 <div class="sync-log-header">
3716 4097 <div class="sync-log-title-section">
3717 - <h2>Changes Log</h2>
3718 - <p style="color: var(--dashboard-text-secondary); margin-bottom: 0;">
3719 - Recent content synchronizations from external tools.
3720 - <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>
3721 4114 </p>
3722 4115 </div>
3723 4116
3724 4117 <!-- Filters + Clear Log button - Right aligned -->
@@ -3727,10 +4120,10 @@
3727 4120 style="background:#dc3545;color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:13px;"
3728 4121 data-nonce="<?php echo esc_attr(wp_create_nonce('metasync_clear_sync_log')); ?>">
3729 4122 🗑 Clear Log
3730 4123 </button>
3731 - <form method="get" class="sync-filters-form" onchange="this.submit()">
3732 - <input type="hidden" name="page" value="<?php echo esc_attr($_GET['page']); ?>">
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;">
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 ?>">
3733 4126
3734 4127 <select name="date_range" class="sync-filter-select">
3735 4128 <option value="all" <?php selected($filters['date_range'] ?? 'all', 'all'); ?>> All Time</option>
3736 4129 <option value="today" <?php selected($filters['date_range'] ?? '', 'today'); ?>>Today</option>
@@ -3752,9 +4145,9 @@
3752 4145 <div class="sync-log-list">
3753 4146 <?php if (empty($sync_records)): ?>
3754 4147 <div class="sync-log-empty">
3755 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>
3756 - <h3>No sync records found</h3>
4149 + <h2>No sync records found</h2>
3757 4150 <p>Sync records will appear here when content/pages receive new updates.</p>
3758 4151 </div>
3759 4152 <?php else: ?>
3760 4153 <?php foreach ($sync_records as $record): ?>
@@ -3779,19 +4172,20 @@
3779 4172 </div>
3780 4173 </div>
3781 4174
3782 4175 <div class="sync-log-status" style="display:flex;align-items:center;gap:8px;">
3783 - <?php if ($record->status === 'published' || $record->status === 'publish'): ?>
3784 - <span class="sync-status-badge sync-status-published">
3785 - <span class="sync-status-icon">✓</span>
3786 - Published
3787 - </span>
3788 - <?php else: ?>
3789 - <span class="sync-status-badge sync-status-draft">
3790 - <span class="sync-status-icon">i</span>
3791 - Draft
3792 - </span>
3793 - <?php endif; ?>
4176 + <?php
4177 + $st = (string) $record->status;
4178 + $b_label = ucfirst($st); $b_bg = '#6b7280'; $b_icon = 'dashicons-info-outline';
4179 + if ($st === 'published' || $st === 'publish' || $st === 'success' || $st === 'partial') { $b_label = 'Published'; $b_bg = '#16a34a'; $b_icon = 'dashicons-yes'; }
4180 + elseif ($st === 'updated') { $b_label = 'Updated'; $b_bg = '#0d9488'; $b_icon = 'dashicons-update'; }
4181 + elseif ($st === 'failed' || $st === 'conflict' || $st === 'locked') { $b_label = 'Not imported'; $b_bg = '#64748b'; $b_icon = 'dashicons-minus'; }
4182 + elseif ($st === 'draft') { $b_label = 'Draft'; $b_bg = '#6b7280'; $b_icon = 'dashicons-info-outline'; }
4183 + ?>
4184 + <span class="sync-status-badge" style="display:inline-flex;align-items:center;gap:4px;background:<?php echo esc_attr($b_bg); ?>;color:#fff;padding:3px 10px;border-radius:12px;font-size:12px;font-weight:600;line-height:1;">
4185 + <span class="dashicons <?php echo esc_attr($b_icon); ?>" style="font-size:14px;width:14px;height:14px;line-height:14px;"></span>
4186 + <?php echo esc_html($b_label); ?>
4187 + </span>
3794 4188 <?php if ($record->source === 'MCP Client'): ?>
3795 4189 <button type="button"
3796 4190 class="metasync-rollback-btn"
3797 4191 data-id="<?php echo esc_attr($record->id); ?>"
@@ -3807,30 +4201,32 @@
3807 4201 <?php endif; ?>
3808 4202 </div>
3809 4203
3810 4204 <!-- Pagination -->
3811 - <?php if ($total_pages > 1): ?>
3812 - <div class="sync-log-pagination">
3813 - <div class="sync-log-pagination-info">
3814 - Total records: <?php echo intval( $total_records ); ?> | Showing <?php echo intval( $offset ) + 1; ?>-<?php echo intval( min($offset + $per_page, $total_records) ); ?>
3815 - </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>
3816 4211
4212 + <?php if ($total_pages > 1): ?>
3817 4213 <div class="sync-log-pagination-controls">
3818 4214 <?php if ($page > 1): ?>
3819 - <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>
3820 4216 <?php endif; ?>
3821 4217
3822 4218 <?php for ($i = max(1, $page - 2); $i <= min($total_pages, $page + 2); $i++): ?>
3823 - <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) ); ?>"
3824 4220 class="sync-pagination-btn <?php echo $i === $page ? 'active' : ''; ?>"><?php echo intval( $i ); ?></a>
3825 4221 <?php endfor; ?>
3826 4222
3827 4223 <?php if ($page < $total_pages): ?>
3828 - <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>
3829 4225 <?php endif; ?>
3830 4226 </div>
3831 - </div>
3832 - <?php endif; ?>
4227 + <?php endif; ?>
4228 + </div>
3833 4229 </div>
3834 4230 <?php $this->render_layout_close(); ?>
3835 4231
3836 4232 <script>
@@ -3911,8 +4307,19 @@
3911 4307 if (!empty($value)) {
3912 4308 $query_parts[] = $key . '=' . urlencode($value);
3913 4309 }
3914 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 +
3915 4322 return !empty($query_parts) ? '&' . implode('&', $query_parts) : '';
3916 4323 }
3917 4324
3918 4325 /**
@@ -4086,18 +4493,8 @@
4086 4493 {
4087 4494 Metasync_Connect_Manager::instance()->handle_session_management_early();
4088 4495 }
4089 4496
4090 - /**
4091 - * @deprecated 2.5.12 Use Metasync_Auth_Manager instead of sessions for authentication
4092 - */
4093 - private function safe_session_start() {
4094 - // This method is deprecated and no longer used
4095 - // Authentication now uses Metasync_Auth_Manager with WordPress transients and user meta
4096 - _deprecated_function(__METHOD__, '2.5.12', 'Metasync_Auth_Manager');
4097 - return Metasync_Session_Helper::safe_start();
4098 - }
4099 -
4100 4497 private function handle_whitelabel_session_logic()
4101 4498 {
4102 4499 Metasync_Connect_Manager::instance()->handle_whitelabel_session_logic();
4103 4500 }
@@ -4143,8 +4540,9 @@
4143 4540 $stats = Metasync_CPU_Monitor::get_stats();
4144 4541 $per_core_threshold = Metasync_CPU_Monitor::get_per_core_threshold();
4145 4542 $cores = Metasync_CPU_Monitor::get_cpu_core_count();
4146 4543 $effective_threshold = Metasync_CPU_Monitor::get_effective_threshold();
4544 + $detection_reliable = Metasync_CPU_Monitor::is_core_detection_reliable();
4147 4545 ?>
4148 4546 <div style="background: var(--dashboard-card-bg); padding: 20px; border-radius: 8px;">
4149 4547 <!-- CPU Cores Detected -->
4150 4548 <div style="margin-bottom: 24px;">
@@ -4151,12 +4549,20 @@
4151 4549 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4152 4550 CPU Cores Detected
4153 4551 </label>
4154 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);">
4155 - <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; ?>
4156 4558 </div>
4157 4559 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
4158 - 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; ?>
4159 4565 </p>
4160 4566 </div>
4161 4567
4162 4568 <!-- Per-Core Load Threshold -->
@@ -4162,8 +4568,9 @@
4162 4568 <!-- Per-Core Load Threshold -->
4163 4569 <div style="margin-bottom: 24px;">
4164 4570 <label for="cpu_load_per_core_threshold" style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4165 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.'); ?>
4166 4573 </label>
4167 4574 <input type="number"
4168 4575 id="cpu_load_per_core_threshold"
4169 4576 name="metasync_options[performance][cpu_load_per_core_threshold]"
@@ -4183,9 +4590,9 @@
4183 4590 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4184 4591 Effective Threshold
4185 4592 </label>
4186 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);">
4187 - <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>
4188 4595 </div>
4189 4596 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
4190 4597 Calculated as: cores × per-core threshold
4191 4598 </p>
@@ -4203,13 +4610,13 @@
4203 4610 <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo intval($stats['deferrals']); ?></div>
4204 4611 </div>
4205 4612 <div>
4206 4613 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Max Load Observed</div>
4207 - <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>
4208 4615 </div>
4209 4616 <div>
4210 4617 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Average Load</div>
4211 - <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>
4212 4619 </div>
4213 4620 </div>
4214 4621 </div>
4215 4622
@@ -4715,88 +5122,8 @@
4715 5122
4716 5123 /**
4717 5124 * Get the settings option array and print one of its values
4718 5125 */
4719 - public function no_index_posts_callback()
4720 - {
4721 - Metasync_Settings_Fields::instance()->no_index_posts_callback();
4722 - }
4723 -
4724 - /**
4725 - * Get the settings option array and print one of its values
4726 - */
4727 - public function no_follow_links_callback()
4728 - {
4729 - Metasync_Settings_Fields::instance()->no_follow_links_callback();
4730 - }
4731 -
4732 - /**
4733 - * Get the settings option array and print one of its values
4734 - */
4735 - public function open_external_links_callback()
4736 - {
4737 - Metasync_Settings_Fields::instance()->open_external_links_callback();
4738 - }
4739 -
4740 - /**
4741 - * Get the settings option array and print one of its values
4742 - */
4743 - public function add_alt_image_tags_callback()
4744 - {
4745 - Metasync_Settings_Fields::instance()->add_alt_image_tags_callback();
4746 - }
4747 -
4748 - /**
4749 - * Get the settings option array and print one of its values
4750 - */
4751 - public function add_title_image_tags_callback()
4752 - {
4753 - Metasync_Settings_Fields::instance()->add_title_image_tags_callback();
4754 - }
4755 -
4756 - /**
4757 - * Get the settings option array and print one of its values
4758 - */
4759 - public function site_type_callback()
4760 - {
4761 - Metasync_Settings_Fields::instance()->site_type_callback();
4762 - }
4763 -
4764 - /**
4765 - * Get the settings option array and print one of its values
4766 - */
4767 - public function site_business_type_callback()
4768 - {
4769 - Metasync_Settings_Fields::instance()->site_business_type_callback();
4770 - }
4771 -
4772 - /**
4773 - * Get the settings option array and print one of its values
4774 - */
4775 - public function site_company_name_callback()
4776 - {
4777 - Metasync_Settings_Fields::instance()->site_company_name_callback();
4778 - }
4779 -
4780 - /**
4781 - * Get the settings option array and print one of its values
4782 - */
4783 - public function site_google_logo_callback()
4784 - {
4785 - Metasync_Settings_Fields::instance()->site_google_logo_callback();
4786 - }
4787 -
4788 - /**
4789 - * Get the settings option array and print one of its values
4790 - */
4791 - public function site_social_share_image_callback()
4792 - {
4793 - Metasync_Settings_Fields::instance()->site_social_share_image_callback();
4794 - }
4795 -
4796 - /**
4797 - * Get the settings option array and print one of its values
4798 - */
4799 5126 public function common_robot_meta_tags_callback()
4800 5127 {
4801 5128 Metasync_Settings_Fields::instance()->common_robot_meta_tags_callback();
4802 5129 }
@@ -4994,8 +5321,14 @@
4994 5321 * Show a one-time admin notice when a page builder is detected but the
4995 5322 * "Default Page Builder" setting has never been explicitly saved.
4996 5323 */
4997 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 +
4998 5331 $configured = Metasync::get_option('general')['default_page_builder'] ?? '';
4999 5332
5000 5333 // Setting already saved — nothing to warn about
5001 5334 if (!empty($configured)) {
@@ -5008,9 +5341,9 @@
5008 5341 return;
5009 5342 }
5010 5343
5011 5344 // Handle dismiss action
5012 - 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')) {
5013 5346 update_user_meta(get_current_user_id(), 'metasync_builder_notice_dismissed', '1');
5014 5347 return;
5015 5348 }
5016 5349
@@ -5105,9 +5438,10 @@
5105 5438 * Saves the CPU load threshold and returns statistics
5106 5439 */
5107 5440 public function ajax_save_performance_settings() {
5108 5441 # Check nonce for security and return early if invalid
5109 - 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')) {
5110 5444 wp_send_json_error(array('message' => 'Invalid nonce'));
5111 5445 return;
5112 5446 }
5113 5447
@@ -5175,9 +5509,9 @@
5175 5509 if (!wp_next_scheduled('metasync_cleanup_transients')) {
5176 5510 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_cleanup_transients');
5177 5511
5178 5512 if (!$scheduled) {
5179 - 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
5180 5514 }
5181 5515 }
5182 5516 }
5183 5517
@@ -5188,9 +5522,9 @@
5188 5522 {
5189 5523 $timestamp = wp_next_scheduled('metasync_cleanup_transients');
5190 5524 if ($timestamp) {
5191 5525 wp_unschedule_event($timestamp, 'metasync_cleanup_transients');
5192 - 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
5193 5527 }
5194 5528 }
5195 5529
5196 5530 /**
@@ -5212,12 +5546,10 @@
5212 5546 {
5213 5547 if (!wp_next_scheduled('metasync_hidden_post_check')) {
5214 5548 $scheduled = wp_schedule_event(time(), 'metasync_weekly', 'metasync_hidden_post_check');
5215 5549
5216 - if ($scheduled) {
5217 - error_log('MetaSync: Hidden post manager cron job scheduled successfully (runs every 7 days)');
5218 - } else {
5219 - 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
5220 5552 }
5221 5553 }
5222 5554 }
5223 5555
@@ -5228,12 +5560,10 @@
5228 5560 public function maybe_schedule_otto_recheck_404_cron()
5229 5561 {
5230 5562 if (!wp_next_scheduled('metasync_otto_recheck_404_exclusions')) {
5231 5563 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_otto_recheck_404_exclusions');
5232 - if ($scheduled) {
5233 - error_log('MetaSync: OTTO 404 recheck cron job scheduled successfully (runs daily)');
5234 - } else {
5235 - 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
5236 5566 }
5237 5567 }
5238 5568 }
5239 5569
@@ -5306,13 +5636,13 @@
5306 5636
5307 5637 try {
5308 5638 // 1. Post revisions
5309 5639 if (!empty($settings['clean_post_revisions'])) {
5310 - $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
5311 5641 "DELETE FROM {$wpdb->posts} WHERE post_type = 'revision'"
5312 5642 );
5313 5643 // Remove postmeta left behind by deleted revisions
5314 - $wpdb->query(
5644 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5315 5645 "DELETE pm FROM {$wpdb->postmeta} pm
5316 5646 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5317 5647 WHERE p.ID IS NULL"
5318 5648 );
@@ -5320,20 +5650,19 @@
5320 5650
5321 5651 // 2. Trashed posts + their postmeta
5322 5652 if (!empty($settings['clean_trashed_posts'])) {
5323 5653 // Collect IDs first to cleanly remove postmeta
5324 - $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
5325 5655 "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'trash'"
5326 5656 );
5327 5657 if (!empty($trashed_ids)) {
5328 - $placeholders = implode(',', array_fill(0, count($trashed_ids), '%d'));
5329 - $wpdb->query(
5658 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5330 5659 $wpdb->prepare(
5331 - "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')) . ')',
5332 5661 $trashed_ids
5333 5662 )
5334 5663 );
5335 - $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
5336 5665 "DELETE FROM {$wpdb->posts} WHERE post_status = 'trash'"
5337 5666 );
5338 5667 } else {
5339 5668 $stats['trashed_posts'] = 0;
@@ -5341,9 +5670,9 @@
5341 5670 }
5342 5671
5343 5672 // 3. Trashed comments
5344 5673 if (!empty($settings['clean_trashed_comments'])) {
5345 - $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
5346 5675 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'trash'"
5347 5676 );
5348 5677 }
5349 5678
@@ -5348,9 +5677,9 @@
5348 5677 }
5349 5678
5350 5679 // 4. Spam comments
5351 5680 if (!empty($settings['clean_spam_comments'])) {
5352 - $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
5353 5682 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'spam'"
5354 5683 );
5355 5684 }
5356 5685
@@ -5356,15 +5685,15 @@
5356 5685
5357 5686 // 5. Expired transients — direct SQL, no cache flush
5358 5687 if (!empty($settings['clean_expired_transients'])) {
5359 5688 // Delete timeout rows that have already expired
5360 - $wpdb->query(
5689 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5361 5690 "DELETE FROM {$wpdb->options}
5362 5691 WHERE option_name LIKE '\_transient\_timeout\_%'
5363 5692 AND option_value + 0 < UNIX_TIMESTAMP()"
5364 5693 );
5365 5694 // Delete value rows whose timeout row no longer exists
5366 - $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
5367 5696 "DELETE o FROM {$wpdb->options} o
5368 5697 LEFT JOIN {$wpdb->options} t
5369 5698 ON t.option_name = CONCAT('_transient_timeout_', SUBSTRING(o.option_name, 12))
5370 5699 WHERE o.option_name LIKE '\_transient\_%'
@@ -5374,9 +5703,9 @@
5374 5703 }
5375 5704
5376 5705 // 6. Orphaned postmeta (post_id references a post that no longer exists)
5377 5706 if (!empty($settings['clean_orphaned_postmeta'])) {
5378 - $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
5379 5708 "DELETE pm FROM {$wpdb->postmeta} pm
5380 5709 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5381 5710 WHERE p.ID IS NULL"
5382 5711 );
@@ -5388,12 +5717,10 @@
5388 5717 $settings['last_run_at'] = time();
5389 5718 $settings['last_run_stats'] = $stats;
5390 5719 update_option('metasync_db_cleanup_settings', $settings);
5391 5720
5392 - error_log('MetaSync: DB cleanup completed — ' . json_encode($stats));
5393 -
5394 5721 } catch (Exception $e) {
5395 - 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
5396 5723 }
5397 5724 }
5398 5725
5399 5726 /**
@@ -5610,10 +5937,11 @@
5610 5937 /**
5611 5938 * AJAX: Save DB cleanup settings and reschedule cron accordingly.
5612 5939 */
5613 5940 public function ajax_save_db_cleanup_settings() {
5941 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5614 5942 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5615 - !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')) {
5616 5944 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
5617 5945 return;
5618 5946 }
5619 5947
@@ -5670,10 +5998,11 @@
5670 5998 * AJAX: Manually trigger the DB cleanup and return stats for the UI.
5671 5999 * Persists current form state first so unsaved checkbox changes are respected.
5672 6000 */
5673 6001 public function ajax_run_db_cleanup() {
6002 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5674 6003 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5675 - !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')) {
5676 6005 wp_send_json_error(array('message' => 'Invalid security token.'));
5677 6006 return;
5678 6007 }
5679 6008
@@ -5749,35 +6078,8 @@
5749 6078 ));
5750 6079 }
5751 6080
5752 6081 /**
5753 - * Control plugin auto-updates based on user setting
5754 - *
5755 - * @param bool $update Whether to update
5756 - * @param object $item Update offer
5757 - * @return bool Whether to allow auto-update
5758 - */
5759 - public function control_plugin_auto_updates($update, $item)
5760 - {
5761 - # Check if the item object has the slug property
5762 - if (!isset($item->slug)) {
5763 - return $update;
5764 - }
5765 -
5766 - // Check if this is our plugin
5767 - if ($item->slug === 'metasync') {
5768 - $general_settings = Metasync::get_option('general') ?? [];
5769 - $enable_auto_updates = $general_settings['enable_auto_updates'] ?? false;
5770 -
5771 - // Return the user's preference (true = allow auto-updates, false = prevent)
5772 - return $enable_auto_updates === 'true' || $enable_auto_updates === true;
5773 - }
5774 -
5775 - // For other plugins, don't interfere with their auto-update settings
5776 - return $update;
5777 - }
5778 -
5779 - /**
5780 6082 * AJAX handler to add excluded URL for OTTO
5781 6083 */
5782 6084 public function ajax_otto_add_excluded_url()
5783 6085 {
@@ -5923,8 +6225,9 @@
5923 6225 // If page has raw HTML or custom CSS from conversion, show badge
5924 6226 if ($has_raw_html || !empty($has_custom_css)) {
5925 6227 $label = $this->get_html_source_label();
5926 6228 $tooltip = sprintf(
6229 + /* translators: %s: HTML source label. */
5927 6230 __('This page was created using %s HTML-to-Builder converter', 'metasync'),
5928 6231 $label
5929 6232 );
5930 6233
@@ -5963,8 +6266,16 @@
5963 6266 if (!$post || !in_array($post->post_type, array('post', 'page'))) {
5964 6267 return;
5965 6268 }
5966 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 +
5967 6278 $has_raw_html = get_post_meta($post->ID, '_metasync_raw_html_enabled', true);
5968 6279 $has_custom_css = get_post_meta($post->ID, '_metasync_custom_css', true);
5969 6280
5970 6281 if ($has_raw_html || !empty($has_custom_css)) {
@@ -5969,8 +6280,9 @@
5969 6280
5970 6281 if ($has_raw_html || !empty($has_custom_css)) {
5971 6282 $label = $this->get_html_source_label();
5972 6283 $message = sprintf(
6284 + /* translators: %s: HTML source label. */
5973 6285 __('This page was created using %s HTML-to-Builder converter. The design is preserved with custom CSS and inline styles.', 'metasync'),
5974 6286 '<strong>' . esc_html($label) . '</strong>'
5975 6287 );
5976 6288
@@ -5984,9 +6296,9 @@
5984 6296 <p class="metasync-editor-message">%s</p>
5985 6297 </div>
5986 6298 </div>',
5987 6299 esc_html($label),
5988 - $message
6300 + wp_kses($message, array('strong' => array()))
5989 6301 );
5990 6302 }
5991 6303 }
5992 6304
@@ -6009,9 +6321,9 @@
6009 6321 ?>
6010 6322 <fieldset class="inline-edit-col-left metasync-quick-edit-source">
6011 6323 <div class="inline-edit-col">
6012 6324 <label>
6013 - <span class="title"><?php _e('Source', 'metasync'); ?></span>
6325 + <span class="title"><?php esc_html_e('Source', 'metasync'); ?></span>
6014 6326 <span class="metasync-quick-edit-badge-container"></span>
6015 6327 </label>
6016 6328 </div>
6017 6329 </fieldset>
@@ -6023,8 +6335,9 @@
6023 6335 */
6024 6336 public function add_html_pages_dashboard_widget()
6025 6337 {
6026 6338 $label = $this->get_html_source_label();
6339 + /* translators: %s: HTML source label. */
6027 6340 $widget_title = sprintf(__('%s Pages', 'metasync'), $label);
6028 6341
6029 6342 wp_add_dashboard_widget(
6030 6343 'metasync_html_pages_widget',
@@ -6080,8 +6393,14 @@
6080 6393 * @return void Sends JSON response and exits
6081 6394 */
6082 6395 public function ajax_send_giapi()
6083 6396 {
6397 + check_ajax_referer('metasync_nonce', 'nonce');
6398 +
6399 + if (!Metasync::current_user_has_plugin_access()) {
6400 + wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
6401 + }
6402 +
6084 6403 $post_data = metasync_sanitize_input_array($_POST);
6085 6404 if (!isset($post_data['metasync_giapi_url'])) {
6086 6405 return;
6087 6406 }
@@ -6147,8 +6466,14 @@
6147 6466 * @return void Sends JSON response and exits
6148 6467 */
6149 6468 public function ajax_send_bing_indexnow()
6150 6469 {
6470 + check_ajax_referer('metasync_nonce', 'nonce');
6471 +
6472 + if (!Metasync::current_user_has_plugin_access()) {
6473 + wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
6474 + }
6475 +
6151 6476 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
6152 6477 $bing_instant_index = new Metasync_Bing_Instant_Index();
6153 6478 $bing_instant_index->send();
6154 6479 }
@@ -6153,34 +6478,8 @@
6153 6478 $bing_instant_index->send();
6154 6479 }
6155 6480
6156 6481 /**
6157 - * Save instant indexing settings (Google and Bing)
6158 - *
6159 - * @since 2.6.0
6160 - * @return void
6161 - */
6162 - public function save_instant_indexing_settings()
6163 - {
6164 - // Check if this is a settings submission
6165 - if (!isset($_POST['submit'])) {
6166 - return;
6167 - }
6168 -
6169 - // Save post types for Google Instant Indexing auto-submit
6170 - if (isset($_POST['metasync_post_types'])) {
6171 - $post_data = metasync_sanitize_input_array($_POST);
6172 - $post_types = is_array($post_data['metasync_post_types']) ? array_map('sanitize_title', $post_data['metasync_post_types']) : [];
6173 -
6174 - $settings = get_option('metasync_options_instant_indexing', ['post_types' => []]);
6175 - $settings['post_types'] = array_values($post_types);
6176 - update_option('metasync_options_instant_indexing', $settings);
6177 - }
6178 -
6179 - // Note: Bing Instant Indexing settings are saved via AJAX in save_bing_inline_settings_ajax()
6180 - }
6181 -
6182 - /**
6183 6482 * Save Bing instant indexing settings from inline form (Indexation Control page)
6184 6483 *
6185 6484 * @since 2.6.0
6186 6485 * @return bool True on success, false on failure
@@ -6250,12 +6549,24 @@
6250 6549 if (!empty($seo_controls['enable_googleinstantindex']) && $seo_controls['enable_googleinstantindex'] === 'true') {
6251 6550 $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
6252 6551 $post_types = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
6253 6552
6254 - if (in_array($post->post_type, $post_types) && function_exists('google_index_direct')) {
6255 - $service_info = google_index_direct()->get_service_account_info();
6256 - if (!isset($service_info['error'])) {
6257 - 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 + }
6258 6569 }
6259 6570 }
6260 6571 }
6261 6572