PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | admin/class-metasync-admin.php +1016 -377 2.6.7 → 2.7.1 View file →
@@ -33,9 +33,8 @@
33 33 const SECTION_METASYNC = "metasync_settings";
34 34 const SECTION_SEARCHENGINE = "searchengine_settings";
35 35 const SECTION_LOCALSEO = "local_seo";
36 36 const SECTION_CODESNIPPETS = "code_snippets";
37 - const SECTION_OPTIMAL_SETTINGS = "optimal_settings";
38 37 const SECTION_SITE_SETTINGS = "site_settings";
39 38 const SECTION_COMMON_SETTINGS = "common_settings";
40 39 const SECTION_COMMON_META_SETTINGS = "common_meta_settings";
41 40 const SECTION_SOCIAL_META = "social_meta";
@@ -42,9 +41,8 @@
42 41 const SECTION_SEO_CONTROLS = "seo_controls";
43 42 const SECTION_SEO_CONTROLS_ADVANCED = "seo_controls_advanced";
44 43 const SECTION_SEO_CONTROLS_INSTANT_INDEX = "seo_controls_instant_index";
45 44 const SECTION_PLUGIN_VISIBILITY = "plugin_visibility_settings";
46 - const SECTION_BREADCRUMBS = "breadcrumbs_settings";
47 45 const SECTION_LLMS_TXT = "llms_txt_settings";
48 46
49 47 /**
50 48 * The ID of this plugin.
@@ -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
@@ -243,8 +245,9 @@
243 245 #add css into admin header for icon image
244 246
245 247 add_action('admin_head', array($this,'metasync_admin_icon_style'));
246 248 add_action('admin_head', array($this, 'metasync_fouc_prevention_style'));
249 + add_action('admin_head', array($this, 'suppress_notices_on_wizard_page'), 1);
247 250
248 251 // Always add admin bar styles - the method will check the setting internally
249 252 add_action('wp_head', array($this,'metasync_admin_bar_style')); // For frontend admin bar
250 253 add_action('admin_head', array($this,'metasync_admin_bar_style')); // For backend admin bar
@@ -259,8 +262,9 @@
259 262 add_action('metasync_api_key_changed', array('Metasync_Admin_Navigation', 'invalidate_admin_bar_status_cache'), 10, 0);
260 263
261 264 add_action('admin_init', array($this, 'initialize_cookie'));
262 265 add_action('admin_init', array($this, 'maybe_redirect_to_wizard'));
266 + add_action('admin_init', array($this, 'redirect_legacy_instant_indexing_page'));
263 267
264 268 // Add admin_post hooks for form submissions (WordPress standard way - no output buffering needed)
265 269 add_action('admin_post_metasync_clear_all_cache_plugins', array($this, 'handle_clear_all_cache_plugins'));
266 270 add_action('admin_post_metasync_clear_otto_cache_all', array($this, 'handle_clear_otto_cache_all'));
@@ -278,10 +282,16 @@
278 282
279 283 // Add AJAX for saving execution settings
280 284 add_action( 'wp_ajax_metasync_save_execution_settings', array($this, 'ajax_save_execution_settings') );
281 285
286 + // Add AJAX for saving the Headless Mode demo controls on the Advanced tab
287 + add_action( 'wp_ajax_metasync_save_headless_settings', array(Metasync_Settings_Registration::instance(), 'ajax_save_headless_settings') );
288 +
282 289 // Add AJAX for saving hosting cache settings
283 290 add_action( 'wp_ajax_metasync_save_hosting_cache_settings', array($this, 'ajax_save_hosting_cache_settings') );
291 +
292 + // Add AJAX for saving OTTO Cache TTL
293 + add_action('wp_ajax_metasync_save_otto_cache_ttl', array($this, 'ajax_save_otto_cache_ttl'));
284 294 add_action( 'wp_ajax_metasync_save_object_cache_settings', array($this, 'ajax_save_object_cache_settings') );
285 295
286 296 // Add AJAX for saving edge cache / CDN settings
287 297 add_action( 'wp_ajax_metasync_save_edge_cache_settings', array('Metasync_Edge_Cache_Settings', 'ajax_save') );
@@ -306,8 +316,9 @@
306 316 add_action('wp_ajax_metasync_get_default_robots', array($this, 'ajax_get_default_robots'));
307 317 add_action('wp_ajax_metasync_preview_robots_backup', array($this, 'ajax_preview_robots_backup'));
308 318 add_action('wp_ajax_metasync_delete_robots_backup', array($this, 'ajax_delete_robots_backup'));
309 319 add_action('wp_ajax_metasync_restore_robots_backup', array($this, 'ajax_restore_robots_backup'));
320 + add_action('wp_ajax_metasync_get_robots_backups', array($this, 'ajax_get_robots_backups'));
310 321
311 322 // Add AJAX handlers for host blocking test
312 323 add_action('wp_ajax_metasync_test_host_blocking_get', array($this, 'ajax_test_host_blocking_get'));
313 324 add_action('wp_ajax_metasync_test_host_blocking_post', array($this, 'ajax_test_host_blocking_post'));
@@ -359,11 +370,20 @@
359 370 add_action('wp_ajax_metasync_start_batch_optimize', array($this, 'ajax_start_batch_optimize'));
360 371 add_action('wp_ajax_metasync_cancel_batch_optimize', array($this, 'ajax_cancel_batch_optimize'));
361 372 add_action('wp_ajax_metasync_batch_progress', array($this, 'ajax_batch_progress'));
362 373 add_action('wp_ajax_metasync_bulk_optimize_selected', array($this, 'ajax_bulk_optimize_selected'));
374 + add_action('wp_ajax_metasync_bulk_unoptimize_selected', array($this, 'ajax_bulk_unoptimize_selected'));
363 375 add_action('wp_ajax_metasync_process_batch_tick', array($this, 'ajax_process_batch_tick'));
376 + add_action('wp_ajax_metasync_delete_orphaned_image', array($this, 'ajax_delete_orphaned_image'));
364 377 add_action('metasync_media_batch_optimize_cron', array($this, 'handle_media_batch_cron'));
365 378
379 + # SEO Restore (Rollback) AJAX & Cron handlers
380 + add_action('wp_ajax_metasync_seo_restore_start', array($this, 'ajax_seo_restore_start'));
381 + add_action('wp_ajax_metasync_seo_restore_cancel', array($this, 'ajax_seo_restore_cancel'));
382 + add_action('wp_ajax_metasync_seo_restore_progress', array($this, 'ajax_seo_restore_progress'));
383 + add_action('wp_ajax_metasync_seo_restore_process_tick', array($this, 'ajax_seo_restore_process_tick'));
384 + add_action('metasync_seo_restore_cron', array($this, 'handle_seo_restore_cron'));
385 +
366 386 # Add AJAX handlers for Google Instant Indexing
367 387 add_action('wp_ajax_metasync_send_giapi', array($this, 'ajax_send_giapi'));
368 388
369 389 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
@@ -456,9 +476,11 @@
456 476
457 477 $data= Metasync::get_option('general');
458 478
459 479 # Get white label menu slug
460 - $menu_slug = empty($data['white_label_plugin_menu_slug']) ? self::$page_slug : $data['white_label_plugin_menu_slug'];
480 + # Sanitize so a URL-shaped legacy value still yields a valid WP menu slug
481 + $menu_slug = empty($data['white_label_plugin_menu_slug']) ? self::$page_slug : sanitize_title($data['white_label_plugin_menu_slug']);
482 + $menu_slug = $menu_slug === '' ? self::$page_slug : $menu_slug;
461 483
462 484 ?>
463 485 <style>
464 486 #toplevel_page_<?php echo esc_attr(str_replace(' ', '-',$menu_slug)); ?> .wp-menu-image.dashicons-before img {
@@ -475,16 +497,129 @@
475 497 <?php
476 498 }
477 499
478 500 public function metasync_fouc_prevention_style() {
479 - if ( ! isset( $_GET['page'] ) || strpos( $_GET['page'], self::$page_slug ) !== 0 ) {
501 + if ( ! isset( $_GET['page'] ) || strpos( sanitize_key(wp_unslash($_GET['page'])), self::$page_slug ) !== 0 ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check for an inline style
480 502 return;
481 503 }
482 504 ?>
483 - <style>.metasync-dashboard-wrap { opacity: 0; transition: opacity 0.15s ease-in; }</style>
505 + <style>
506 + .metasync-dashboard-wrap { opacity: 0; transition: opacity 0.15s ease-in; }
507 + /*
508 + * WP prints admin notices at the top of #wpbody-content, then core's
509 + * common.js relocates them next to .wp-header-end inside our wrap on
510 + * DOM-ready. Keep them out of the flow until that move so they don't
511 + * flash above the dashboard header and shove the page down.
512 + * Once relocated they're no longer a direct child here, so this rule
513 + * stops matching and they fade in with the wrap. We mirror core's
514 + * relocation set exactly (.notice/.updated/.error, minus .inline and
515 + * .below-h2); .update-nag is left alone since core never moves it.
516 + */
517 + #wpbody-content > .notice:not(.inline):not(.below-h2),
518 + #wpbody-content > .updated:not(.inline):not(.below-h2),
519 + #wpbody-content > .error:not(.inline):not(.below-h2) { display: none !important; }
520 + </style>
484 521 <?php
485 522 }
486 523
524 + public function suppress_notices_on_wizard_page() {
525 + if ( ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
526 + return;
527 + }
528 +
529 + $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
530 +
531 + // Setup wizard: strip every notice for a fully focused, distraction-free screen.
532 + if ( strpos( $page, '-setup-wizard' ) !== false ) {
533 + remove_all_actions( 'admin_notices' );
534 + remove_all_actions( 'all_admin_notices' );
535 + return;
536 + }
537 +
538 + // Other Search Atlas plugin pages: strip third-party notices but keep our own.
539 + if ( strpos( $page, self::$page_slug ) === 0 ) {
540 + $this->remove_third_party_admin_notices();
541 + }
542 + }
543 +
544 + /**
545 + * Remove admin-notice callbacks that don't belong to this plugin.
546 + *
547 + * Third-party plugins (e.g. Rank Math, Yoast) register their banners on the
548 + * global admin_notices / all_admin_notices actions, which WordPress fires on
549 + * every admin screen — so their notices leak onto our settings pages. We walk
550 + * the registered callbacks and drop any whose code lives outside this plugin's
551 + * directory, leaving every one of our own notices intact.
552 + */
553 + private function remove_third_party_admin_notices() {
554 + global $wp_filter;
555 +
556 + foreach ( array( 'admin_notices', 'all_admin_notices' ) as $hook ) {
557 + if ( empty( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof WP_Hook ) ) {
558 + continue;
559 + }
560 +
561 + // Collect first, then remove — avoids mutating the callbacks array mid-walk.
562 + $to_remove = array();
563 + foreach ( $wp_filter[ $hook ]->callbacks as $priority => $callbacks ) {
564 + foreach ( $callbacks as $callback ) {
565 + if ( ! $this->is_own_admin_notice_callback( $callback['function'] ) ) {
566 + $to_remove[] = array( $callback['function'], $priority );
567 + }
568 + }
569 + }
570 +
571 + foreach ( $to_remove as $entry ) {
572 + remove_action( $hook, $entry[0], $entry[1] );
573 + }
574 + }
575 + }
576 +
577 + /**
578 + * Determine whether an admin-notice callback is defined by this plugin.
579 + *
580 + * Ownership is decided by where the callback's code physically lives, not by
581 + * class name — our notice owners use inconsistent prefixes (Metasync_Admin,
582 + * Google_Index_Admin, ConfigControllerMetaSync), and a white-label install can
583 + * rename the plugin folder. Resolving the declaring file and checking it sits
584 + * inside this plugin's directory is robust against all of that. If a callback
585 + * can't be introspected, we keep it rather than risk hiding a legitimate notice.
586 + *
587 + * @param string|array|Closure $function The registered callback.
588 + * @return bool True when the callback's code lives inside this plugin.
589 + */
590 + private function is_own_admin_notice_callback( $function ) {
591 + // Plugin root: this file lives in <plugin>/admin, so its parent is the root.
592 + $plugin_dir = wp_normalize_path( dirname( __DIR__ ) ) . '/';
593 +
594 + try {
595 + if ( is_array( $function ) && isset( $function[0], $function[1] ) ) {
596 + $class = is_object( $function[0] ) ? get_class( $function[0] ) : $function[0];
597 + $ref = new ReflectionMethod( $class, $function[1] );
598 + } elseif ( $function instanceof Closure ) {
599 + $ref = new ReflectionFunction( $function );
600 + } elseif ( is_string( $function ) && strpos( $function, '::' ) !== false ) {
601 + list( $class, $method ) = explode( '::', $function, 2 );
602 + $ref = new ReflectionMethod( $class, $method );
603 + } elseif ( is_string( $function ) ) {
604 + $ref = new ReflectionFunction( $function );
605 + } else {
606 + return false;
607 + }
608 + } catch ( \Throwable $e ) {
609 + // Unintrospectable callback — keep it; never hide a notice we can't classify.
610 + return true;
611 + }
612 +
613 + $file = $ref->getFileName();
614 + if ( ! $file ) {
615 + // Internal/built-in callback (no source file) — not ours.
616 + return false;
617 + }
618 +
619 + return strpos( wp_normalize_path( $file ), $plugin_dir ) === 0;
620 + }
621 +
487 622 #---------fixes issue : #95 ----------
488 623 #This function is to redirect in case client changes slug on fresh install
489 624 #It is called by the add_option hook
490 625
@@ -510,9 +645,9 @@
510 645
511 646 #Redirect url to the new slug
512 647 $redirect_url = admin_url('admin.php?page=' . $current_slug);
513 648
514 - wp_redirect($redirect_url);
649 + wp_safe_redirect($redirect_url);
515 650 exit;
516 651 }
517 652 }
518 653
@@ -564,13 +699,13 @@
564 699 * @since 1.0.0
565 700 */
566 701 public function maybe_redirect_to_wizard() {
567 702 // Only redirect if wizard should be shown
568 - if (get_option('metasync_show_wizard') && !isset($_GET['page'])) {
703 + if (get_option('metasync_show_wizard') && !isset($_GET['page'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- isset() routing check for a redirect to a fixed admin URL
569 704 delete_option('metasync_show_wizard');
570 705
571 706 // Don't redirect during AJAX, cron, or bulk activation
572 - if (wp_doing_ajax() || wp_doing_cron() || isset($_GET['activate-multi'])) {
707 + if (wp_doing_ajax() || wp_doing_cron() || isset($_GET['activate-multi'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- isset() routing check for a redirect to a fixed admin URL
573 708 return;
574 709 }
575 710
576 711 // Only redirect if user has access to the plugin
@@ -582,11 +717,56 @@
582 717 exit;
583 718 }
584 719 }
585 720
721 + /**
722 + * Redirect the removed standalone Instant Indexing page to Indexation Control.
723 + *
724 + * The menu entry is gone, but bookmarks and external links can still target
725 + * the old URL. Resolve the current white-label slug rather than assuming
726 + * "searchatlas".
727 + */
728 + public function redirect_legacy_instant_indexing_page() {
729 + if (wp_doing_ajax() || wp_doing_cron() || !is_admin() || !isset($_GET['page'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
730 + return;
731 + }
732 +
733 + $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
734 + $legacy_page = self::$page_slug . '-instant-index';
735 + if ($page !== $legacy_page || !Metasync::current_user_has_plugin_access()) {
736 + return;
737 + }
738 +
739 + // Indexation Control is registered only when access control keeps it
740 + // visible. Redirecting to it blindly would dead-end on an
741 + // unregistered slug (wp_die "Invalid plugin page") for users whose
742 + // white-label config hides that page — the exact configs where the
743 + // removed standalone page was the feature's only UI. The bare plugin
744 + // slug is the floor: its top-level callback resolves to the first
745 + // reachable page, so it is always a safe target.
746 + if (Metasync_Access_Control::user_can_access('hide_indexation_control')) {
747 + $target = admin_url('admin.php?page=' . self::$page_slug . '-seo-controls');
748 + $query = [];
749 + foreach (['tab', 'subtab'] as $key) {
750 + if (isset($_GET[$key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
751 + $query[$key] = sanitize_key(wp_unslash($_GET[$key])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
752 + }
753 + }
754 + if (!empty($query)) {
755 + $target = add_query_arg($query, $target);
756 + }
757 + } else {
758 + $target = admin_url('admin.php?page=' . self::$page_slug);
759 + }
760 +
761 + wp_safe_redirect($target);
762 + exit;
763 + }
764 +
586 765 public function metasync_display_error_log() {
587 766 Metasync_Debug_Manager::instance()->metasync_display_error_log($this);
588 767 }
768 +
589 769 public function metasync_update_wp_config() {
590 770 Metasync_Debug_Manager::instance()->metasync_update_wp_config();
591 771 }
592 772
@@ -629,9 +809,9 @@
629 809 ]);
630 810 $_COOKIE['metasync_previous_slug'] = $new_slug;
631 811 // Redirect to the new slug
632 812 $redirect_url = admin_url('admin.php?page=' . $old_slug);
633 - wp_redirect($redirect_url);
813 + wp_safe_redirect($redirect_url);
634 814 exit;
635 815 }else{
636 816 self::$page_slug = Metasync::get_option('general')['white_label_plugin_menu_slug']=="" ? "searchatlas":Metasync::get_option('general')['white_label_plugin_menu_slug'];
637 817 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
@@ -637,9 +817,9 @@
637 817 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
638 818
639 819 #add redirection for when the old slug is not defined
640 820 #this fixes the redirect issue #
641 - wp_redirect($redirect_url);
821 + wp_safe_redirect($redirect_url);
642 822 exit;
643 823 }
644 824 }
645 825 public function metasync_view_detials_url( $plugin_meta, $plugin_file, $plugin_data ) {
@@ -650,9 +830,10 @@
650 830 if (strpos($meta, 'open-plugin-details-modal') !== false) {
651 831 $meta = sprintf(
652 832 '<a href="%s" class="thickbox open-plugin-details-modal" aria-label="%s" data-title="%s">%s</a>',
653 833 add_query_arg('TB_iframe', 'true', $plugin_uri),
654 - esc_attr(sprintf(esc_html__('More information about %s'), $plugin_data['Name'])),
834 + /* translators: %s: plugin name. */
835 + esc_attr(sprintf(esc_html__('More information about %s', 'metasync'), $plugin_data['Name'])),
655 836 esc_attr($plugin_data['Name']),
656 837 esc_html__('View details', 'metasync')
657 838 );
658 839 break; // Exit loop after replacing the link
@@ -700,53 +881,8 @@
700 881 return $all_plugins;
701 882 }
702 883
703 884 /**
704 - * Inject the whitelabel icon into the update_plugins transient so that
705 - * /wp-admin/update-core.php (Dashboard → Updates) shows the WL icon
706 - * instead of the SearchAtlas icon returned by the update API.
707 - *
708 - * @param object $transient The site transient object.
709 - * @return object
710 - */
711 - public function inject_whitelabel_icon_into_update_transient($transient) {
712 - if (empty($transient) || !is_object($transient)) {
713 - return $transient;
714 - }
715 -
716 - $general = Metasync::get_option('general');
717 - if (!is_array($general)) {
718 - return $transient;
719 - }
720 -
721 - $icon_url = $general['white_label_plugin_menu_icon'] ?? '';
722 - if (empty($icon_url)) {
723 - return $transient;
724 - }
725 -
726 - $this_plugin = plugin_basename(dirname(__DIR__) . '/metasync.php');
727 -
728 - // Inject into pending updates list
729 - if (!empty($transient->response) && isset($transient->response[$this_plugin])) {
730 - $transient->response[$this_plugin]->icons = [
731 - '1x' => $icon_url,
732 - '2x' => $icon_url,
733 - ];
734 - }
735 -
736 - // Also inject into the "no update needed" list so the icon appears
737 - // on the updates screen even when the plugin is up-to-date
738 - if (!empty($transient->no_update) && isset($transient->no_update[$this_plugin])) {
739 - $transient->no_update[$this_plugin]->icons = [
740 - '1x' => $icon_url,
741 - '2x' => $icon_url,
742 - ];
743 - }
744 -
745 - return $transient;
746 - }
747 -
748 - /**
749 885 * Register the stylesheets for the admin area.
750 886 *
751 887 * @since 1.0.0
752 888 */
@@ -751,9 +887,9 @@
751 887 * @since 1.0.0
752 888 */
753 889 public function enqueue_styles()
754 890 {
755 - $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
891 + $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
756 892
757 893 if (strpos($current_page, self::$page_slug) === 0) {
758 894 wp_enqueue_style(
759 895 $this->plugin_name,
@@ -782,9 +918,9 @@
782 918 );
783 919 }
784 920
785 921 // Enqueue wizard CSS if on wizard page
786 - if (isset($_GET['page']) && strpos($_GET['page'], '-setup-wizard') !== false) {
922 + if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-setup-wizard') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
787 923 wp_enqueue_style(
788 924 $this->plugin_name . '-setup-wizard',
789 925 plugin_dir_url(__FILE__) . 'css/metasync-setup-wizard.css',
790 926 array($this->plugin_name . '-dashboard'),
@@ -793,9 +929,9 @@
793 929 );
794 930 }
795 931
796 932 // Enqueue SEO Health CSS if on the SEO Health page
797 - if (isset($_GET['page']) && strpos($_GET['page'], '-seo-health') !== false) {
933 + if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-seo-health') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
798 934 wp_enqueue_style(
799 935 $this->plugin_name . '-seo-health',
800 936 plugin_dir_url(__FILE__) . 'css/metasync-seo-health.css',
801 937 array($this->plugin_name . '-dashboard'),
@@ -812,9 +948,9 @@
812 948 */
813 949 public function enqueue_scripts()
814 950 {
815 951 // --- Phase 5 (#887): Extracted inline JS files ---
816 - $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
952 + $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick scripts
817 953 $plugin_root_url = plugin_dir_url(dirname(__FILE__));
818 954 $is_metasync_page = ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0);
819 955
820 956 if ($is_metasync_page) {
@@ -844,10 +980,49 @@
844 980 array('jquery'),
845 981 $this->version,
846 982 true
847 983 );
984 +
985 + // Keeps core's admin-menu pinning in step with pages whose height
986 + // changes after load. Depends on 'common' so core has bound its
987 + // own handlers before this runs.
988 + wp_enqueue_script(
989 + $this->plugin_name . '-admin-menu-height',
990 + plugin_dir_url(__FILE__) . 'js/metasync-admin-menu-height.js',
991 + array('jquery', 'common'),
992 + $this->version,
993 + true
994 + );
848 995 }
849 996
997 + $per_page_screens = [
998 + self::$page_slug . '-redirections',
999 + self::$page_slug . '-404-monitor',
1000 + self::$page_slug . '-sync-log',
1001 + self::$page_slug . '-media-optimization',
1002 + ];
1003 +
1004 + if (in_array($current_page, $per_page_screens, true)) {
1005 + // Results-per-page selectors use this external handler instead of
1006 + // an inline event attribute so changing a page size always reloads
1007 + // the current admin page.
1008 + wp_enqueue_script(
1009 + $this->plugin_name . '-per-page',
1010 + plugin_dir_url(__FILE__) . 'js/metasync-per-page.js',
1011 + array(),
1012 + $this->version,
1013 + true
1014 + );
1015 + // Selector navigation is a GET, so the per-page nonce rides along
1016 + // as a query parameter and gates the user_meta persist in
1017 + // Metasync_Per_Page_Helper::resolve().
1018 + wp_localize_script(
1019 + $this->plugin_name . '-per-page',
1020 + 'metasyncPerPage',
1021 + array('nonces' => Metasync_Per_Page_Helper::page_nonces())
1022 + );
1023 + }
1024 +
850 1025 // Dashboard iframe height (only on dashboard page)
851 1026 if ($current_page === self::$page_slug . '-dashboard' || $current_page === self::$page_slug) {
852 1027 wp_enqueue_script(
853 1028 $this->plugin_name . '-iframe',
@@ -896,12 +1071,12 @@
896 1071 true
897 1072 );
898 1073 // Pass the active tab from server (handles POST redirect)
899 1074 $sitemap_active_tab = 'general';
900 - if (isset($_GET['tab']) && in_array($_GET['tab'], ['general', 'news', 'video'], true)) {
901 - $sitemap_active_tab = sanitize_text_field(wp_unslash($_GET['tab']));
902 - } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) {
903 - $sitemap_active_tab = sanitize_text_field(wp_unslash($_POST['redirect_tab']));
1075 + if (isset($_GET['tab']) && in_array($_GET['tab'], ['general', 'news', 'video'], true)) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed tab selection for the UI
1076 + $sitemap_active_tab = sanitize_text_field(wp_unslash($_GET['tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed tab selection for the UI
1077 + } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only, allow-listed tab selection for the UI
1078 + $sitemap_active_tab = sanitize_text_field(wp_unslash($_POST['redirect_tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only, allow-listed tab selection for the UI
904 1079 }
905 1080 wp_localize_script($this->plugin_name . '-sitemap-tabs', 'metasyncSitemapTabs', [
906 1081 'activeTab' => $sitemap_active_tab,
907 1082 ]);
@@ -1032,9 +1207,8 @@
1032 1207 true
1033 1208 );
1034 1209 wp_localize_script($this->plugin_name . '-connect', 'metasyncConnectData', array(
1035 1210 'optionKey' => self::option_key,
1036 - 'storedPassword' => isset($whitelabel_data['settings_password']) ? $whitelabel_data['settings_password'] : '',
1037 1211 'adminPostUrl' => admin_url('admin-post.php'),
1038 1212 'exportNonce' => wp_create_nonce('metasync_export_whitelabel'),
1039 1213 'ajaxUrl' => admin_url('admin-ajax.php'),
1040 1214 'logoutNonceField' => wp_nonce_field('whitelabel_logout_nonce', 'whitelabel_logout_nonce', true, false),
@@ -1122,8 +1296,11 @@
1122 1296 array('jquery'),
1123 1297 $this->version,
1124 1298 true
1125 1299 );
1300 + wp_localize_script($this->plugin_name . '-bing-console', 'metasyncBingConsoleData', array(
1301 + 'nonce' => wp_create_nonce('metasync_nonce'),
1302 + ));
1126 1303 }
1127 1304
1128 1305 // Add redirection form
1129 1306 if ($current_page === self::$page_slug . '-redirections') {
@@ -1227,8 +1404,10 @@
1227 1404 'nonce' => wp_create_nonce('metasync_nonce'),
1228 1405 'sa_connect_nonce' => $sa_connect_nonce,
1229 1406 'reset_auth_nonce' => wp_create_nonce('metasync_reset_auth_nonce'),
1230 1407 'burst_ping_nonce' => wp_create_nonce('metasync_burst_ping'),
1408 + 'clear_otto_cache_nonce' => wp_create_nonce('metasync_clear_otto_cache'),
1409 + 'lglogin_nonce' => wp_create_nonce('metasync_lglogin'),
1231 1410 'heartbeat_state' => $heartbeat_state,
1232 1411 'dashboard_domain' => self::get_effective_dashboard_domain(),
1233 1412 'support_email' => Metasync::SUPPORT_EMAIL,
1234 1413 'documentation_domain' => Metasync::DOCUMENTATION_DOMAIN,
@@ -1292,9 +1471,9 @@
1292 1471 add_action('admin_notices', array($this, 'display_page_builder_notice'));
1293 1472 // Display update warning banner if plugin update is available
1294 1473 add_action('admin_notices', array($this, 'display_update_warning_banner'));
1295 1474 // Enqueue wizard assets if on wizard page
1296 - if (isset($_GET['page']) && strpos($_GET['page'], '-setup-wizard') !== false) {
1475 + if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-setup-wizard') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick scripts
1297 1476 wp_enqueue_script(
1298 1477 $this->plugin_name . '-setup-wizard',
1299 1478 plugin_dir_url(__FILE__) . 'js/metasync-setup-wizard.js',
1300 1479 array('jquery'),
@@ -1318,14 +1497,44 @@
1318 1497 }
1319 1498
1320 1499 /**
1321 1500 * Settings of HeartBeat API for admin area.
1322 - * Set time interval of send request.
1501 + *
1502 + * The only thing the plugin rides the beat for is a periodic admin
1503 + * telemetry ping (the `heartbeat-send` handler in admin/js/metasync-admin.js),
1504 + * so it slows the beat down rather than speeding it up. Two contexts are
1505 + * left on whatever core decided, because core leans on the beat there:
1506 + *
1507 + * - The front end, where the beat drives wp-auth-check — the "your session
1508 + * has expired, log in again" prompt.
1509 + * - The editor screens, where each tick renews the post lock. A lock only
1510 + * lives for wp_check_post_lock_window() seconds (150 by default), so any
1511 + * interval longer than that drops the lock between beats and lets a
1512 + * second editor silently take over a post someone still has open.
1513 + *
1514 + * Core clamps this to 1-3600 seconds in wp-includes/js/heartbeat.js, so an
1515 + * over-long interval is not corrected on our behalf.
1516 + *
1517 + * @param array $settings Heartbeat settings array.
1518 + * @return array Heartbeat settings array.
1323 1519 */
1324 1520 function metasync_heartbeat_settings($settings)
1325 1521 {
1326 - global $heartbeat_frequency;
1327 - $settings['interval'] = 300;
1522 + if (!is_admin()) {
1523 + return $settings;
1524 + }
1525 +
1526 + # $pagenow is set in wp-includes/vars.php, long before this filter runs;
1527 + # core's own wp_heartbeat_set_suspension() reads it exactly this way.
1528 + global $pagenow;
1529 + $lock_dependent_screens = array('post.php', 'post-new.php', 'customize.php');
1530 + if (isset($pagenow) && in_array($pagenow, $lock_dependent_screens, true)) {
1531 + return $settings;
1532 + }
1533 +
1534 + # 60s matches heartbeat.js's own default and stays well inside the
1535 + # 150s post-lock window, so nothing core does can fall through a gap.
1536 + $settings['interval'] = 60;
1328 1537 return $settings;
1329 1538 }
1330 1539
1331 1540 /**
@@ -1463,13 +1672,8 @@
1463 1672 {
1464 1673 Metasync_Connect_Manager::instance()->test_searchatlas_ajax_endpoint();
1465 1674 }
1466 1675
1467 - public function simple_ajax_test()
1468 - {
1469 - Metasync_Connect_Manager::instance()->simple_ajax_test();
1470 - }
1471 -
1472 1676 /**
1473 1677 * Create Admin Dashboard Iframe Page
1474 1678 * Embeds the Search Atlas dashboard directly in WordPress admin
1475 1679 */
@@ -1494,8 +1698,47 @@
1494 1698 // Get cache count
1495 1699 $cache_count = Metasync_Otto_Transient_Cache::get_cache_count();
1496 1700
1497 1701 ?>
1702 + <!-- OTTO Cache TTL Setting -->
1703 + <div style="margin-bottom: 30px; padding-top: 20px;">
1704 + <h4 style="margin-top: 0; color: var(--dashboard-text-primary);"><?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> Cache TTL</h4>
1705 + <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">
1706 + Configure how long <?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> API suggestions are cached before a fresh API call. The stale fallback expires after 12 hours.
1707 + </p>
1708 +
1709 + <div style="background: rgba(255,255,255,0.02); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
1710 + <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
1711 + <label for="metasync-otto-cache-ttl" style="color: var(--dashboard-text-primary); font-weight: 500;">
1712 + Cache TTL (minutes):
1713 + </label>
1714 + <?php Metasync::render_tooltip_icon('otto_cache_ttl', 'How many minutes OTTO reuses its last SEO suggestions before fetching fresh ones. 30 is fine for most sites.'); ?>
1715 + <input type="number"
1716 + id="metasync-otto-cache-ttl"
1717 + value="<?php echo esc_attr($this->get_otto_cache_ttl_minutes()); ?>"
1718 + min="30"
1719 + max="1440"
1720 + step="1"
1721 + style="width: 100px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg, rgba(255,255,255,0.05)); color: var(--dashboard-text-primary);" />
1722 + <span style="color: var(--dashboard-text-secondary); font-size: 13px;">min 30 · max 1440</span>
1723 + </div>
1724 +
1725 + <div style="display: flex; align-items: center; gap: 12px;">
1726 + <button type="button"
1727 + id="metasync-otto-ttl-save-btn"
1728 + class="metasync-btn-primary"
1729 + style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 9px 18px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease;"
1730 + onmouseover="this.style.transform='translateY(-1px)';"
1731 + onmouseout="this.style.transform='translateY(0)';">
1732 + Save TTL
1733 + </button>
1734 + <span id="metasync-otto-ttl-save-msg" style="display: none; font-size: 13px;"></span>
1735 + </div>
1736 +
1737 + <input type="hidden" id="metasync-otto-ttl-nonce" value="<?php echo esc_attr(wp_create_nonce('metasync_otto_cache_ttl_nonce')); ?>" />
1738 + </div>
1739 + </div>
1740 +
1498 1741 <!-- Cache Plugin Management -->
1499 1742 <div style="margin-bottom: 30px;">
1500 1743 <h4 style="margin-top: 0; color: var(--dashboard-text-primary);">Clear All Cache Plugins</h4>
1501 1744 <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">Clear all cache plugins to ensure changes are visible immediately.</p>
@@ -1517,9 +1760,9 @@
1517 1760 } else {
1518 1761 echo '<p style="color: var(--dashboard-text-secondary);">ℹ️ No cache plugins detected.</p>';
1519 1762 }
1520 1763 } catch (Exception $e) {
1521 - error_log('MetaSync Cache Status Error: ' . $e->getMessage());
1764 + error_log('MetaSync Cache Status Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1522 1765 echo '<p style="color: var(--dashboard-error);">⚠️ An error occurred while retrieving cache plugin status.</p>';
1523 1766 }
1524 1767 } else {
1525 1768 echo '<p style="color: var(--dashboard-error);">⚠️ Cache Purge class not loaded.</p>';
@@ -1529,9 +1772,9 @@
1529 1772 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="margin-top: 15px;">
1530 1773 <input type="hidden" name="action" value="metasync_clear_all_cache_plugins" />
1531 1774 <?php wp_nonce_field('metasync_clear_cache_nonce', 'clear_cache_nonce'); ?>
1532 1775 <button type="submit" class="metasync-btn-primary" style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 10px 20px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); display: inline-block; width: auto; min-width: 240px; max-width: fit-content;" onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 8px rgba(0, 0, 0, 0.15)';" onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 2px 4px rgba(0, 0, 0, 0.1)';">
1533 - <span class="dashicons dashicons-controls-repeat" style="margin-top:3px;font-size:15px;width:15px;height:15px;"></span> Clear All Cache Plugins
1776 + <span class="dashicons dashicons-controls-repeat"></span> Clear All Cache Plugins
1534 1777 </button>
1535 1778 <p class="description" style="margin-top: 10px; color: var(--dashboard-text-secondary);">This will clear cache from WP Rocket, LiteSpeed, W3 Total Cache, and all other detected cache plugins.</p>
1536 1779 </form>
1537 1780
@@ -1536,12 +1779,12 @@
1536 1779 </form>
1537 1780
1538 1781 <?php
1539 1782 // Display success/error messages
1540 - if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') {
1541 - $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0;
1542 - $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0;
1543 - $plugins = isset($_GET['plugins']) ? sanitize_text_field(wp_unslash($_GET['plugins'])) : '';
1783 + if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1784 + $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1785 + $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1786 + $plugins = isset($_GET['plugins']) ? sanitize_text_field(wp_unslash($_GET['plugins'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1544 1787
1545 1788 if ($cleared > 0) {
1546 1789 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1547 1790 echo '✅ <strong>Success!</strong> Cleared cache for ' . intval( $cleared ) . ' plugin(s)';
@@ -1561,10 +1804,10 @@
1561 1804 echo '</p></div>';
1562 1805 }
1563 1806 }
1564 1807
1565 - if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') {
1566 - $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : '';
1808 + if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1809 + $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1567 1810 if (empty($message)) {
1568 1811 $message = 'An unknown error occurred while clearing cache. Please check error logs for details.';
1569 1812 }
1570 1813 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
@@ -1613,8 +1856,9 @@
1613 1856 <?php checked(true, !empty($hosting_settings['wpengine_enabled'])); ?>
1614 1857 <?php echo !$wpe_detected ? 'disabled' : ''; ?>
1615 1858 style="width: 16px; height: 16px; cursor: <?php echo $wpe_detected ? 'pointer' : 'not-allowed'; ?>;" />
1616 1859 <span style="color: var(--dashboard-text-primary); font-weight: 500;">WP Engine</span>
1860 + <?php Metasync::render_tooltip_icon('hosting_cache_wpengine', 'If hosted on WP Engine, turn on so OTTO clears the host\'s built-in cache when it updates a page.'); ?>
1617 1861 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges Varnish + Memcached</span>
1618 1862 </label>
1619 1863
1620 1864 <label style="display: flex; align-items: center; gap: 10px; margin-bottom: 16px; cursor: pointer;">
@@ -1623,8 +1867,9 @@
1623 1867 <?php checked(true, !empty($hosting_settings['kinsta_enabled'])); ?>
1624 1868 <?php echo !$kinsta_detected ? 'disabled' : ''; ?>
1625 1869 style="width: 16px; height: 16px; cursor: <?php echo $kinsta_detected ? 'pointer' : 'not-allowed'; ?>;" />
1626 1870 <span style="color: var(--dashboard-text-primary); font-weight: 500;">Kinsta</span>
1871 + <?php Metasync::render_tooltip_icon('hosting_cache_kinsta', 'If hosted on Kinsta, turn on so OTTO clears Kinsta\'s server cache automatically.'); ?>
1627 1872 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges full-page cache (kinsta_cache_purge_full)</span>
1628 1873 </label>
1629 1874
1630 1875 <div style="display: flex; align-items: center; gap: 12px;">
@@ -1638,9 +1883,9 @@
1638 1883 </button>
1639 1884 <span id="metasync-hc-save-msg" style="display: none; font-size: 13px;"></span>
1640 1885 </div>
1641 1886
1642 - <input type="hidden" id="metasync-hc-nonce" value="<?php echo wp_create_nonce('metasync_hosting_cache_nonce'); ?>" />
1887 + <input type="hidden" id="metasync-hc-nonce" value="<?php echo esc_attr(wp_create_nonce('metasync_hosting_cache_nonce')); ?>" />
1643 1888 </div>
1644 1889
1645 1890 <!-- Purge button -->
1646 1891 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
@@ -1659,12 +1904,13 @@
1659 1904 </form>
1660 1905
1661 1906 <?php
1662 1907 // Hosting cache result messages
1663 - if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') {
1664 - $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode($_GET['hc_cleared'])) : '';
1665 - $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode($_GET['hc_failed'])) : '';
1666 - $hc_not_detected = isset($_GET['hc_not_detected']) ? sanitize_text_field(urldecode($_GET['hc_not_detected'])) : '';
1908 + if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1909 + // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each value is unslashed and sanitized on the following assignments.
1910 + $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_cleared']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1911 + $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_failed']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1912 + $hc_not_detected = isset($_GET['hc_not_detected']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_not_detected']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1667 1913
1668 1914 if ($hc_cleared) {
1669 1915 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1670 1916 echo '✅ <strong>Success!</strong> Purged hosting cache on: ' . esc_html($hc_cleared);
@@ -1679,8 +1925,9 @@
1679 1925 echo '<div class="notice notice-info inline" style="margin-top: 10px;"><p>';
1680 1926 echo 'ℹ️ No enabled hosting providers were detected on this server (' . esc_html($hc_not_detected) . ').';
1681 1927 echo '</p></div>';
1682 1928 }
1929 + // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1683 1930 }
1684 1931 ?>
1685 1932
1686 1933 <script>
@@ -1715,8 +1962,46 @@
1715 1962 setTimeout(function() { $msg.fadeOut(); }, 4000);
1716 1963 }
1717 1964 });
1718 1965 });
1966 +
1967 + // OTTO Cache TTL save
1968 + $('#metasync-otto-ttl-save-btn').on('click', function() {
1969 + var $btn = $(this);
1970 + var $msg = $('#metasync-otto-ttl-save-msg');
1971 + var ttl = parseInt($('#metasync-otto-cache-ttl').val(), 10);
1972 +
1973 + if (isNaN(ttl) || ttl < 30 || ttl > 1440) {
1974 + $msg.text('❌ TTL must be between 30 and 1440 minutes.').css('color', '#ef4444').show();
1975 + return;
1976 + }
1977 +
1978 + $btn.prop('disabled', true).text('Saving…');
1979 +
1980 + $.ajax({
1981 + url: ajaxurl,
1982 + type: 'POST',
1983 + data: {
1984 + action: 'metasync_save_otto_cache_ttl',
1985 + otto_cache_ttl_nonce: $('#metasync-otto-ttl-nonce').val(),
1986 + otto_cache_ttl: ttl,
1987 + },
1988 + success: function(response) {
1989 + if (response.success) {
1990 + $msg.text('✅ Saved').css('color', '#22c55e').show();
1991 + } else {
1992 + $msg.text('❌ ' + (response.data && response.data.message ? response.data.message : 'Save failed')).css('color', '#ef4444').show();
1993 + }
1994 + },
1995 + error: function() {
1996 + $msg.text('❌ Request failed').css('color', '#ef4444').show();
1997 + },
1998 + complete: function() {
1999 + $btn.prop('disabled', false).text('Save TTL');
2000 + setTimeout(function() { $msg.fadeOut(); }, 4000);
2001 + }
2002 + });
2003 + });
1719 2004 });
1720 2005 </script>
1721 2006 </div>
1722 2007
@@ -1734,9 +2019,9 @@
1734 2019 id="metasync-targeted-object-cache"
1735 2020 <?php checked('1', $targeted_cache_enabled); ?>
1736 2021 style="width: 16px; height: 16px; cursor: pointer; margin-top: 2px; flex-shrink: 0;" />
1737 2022 <span>
1738 - <span style="color: var(--dashboard-text-primary); font-weight: 500; display: block; margin-bottom: 4px;">Targeted Object Cache Purge</span>
2023 + <span style="color: var(--dashboard-text-primary); font-weight: 500; display: block; margin-bottom: 4px;">Targeted Object Cache Purge<?php Metasync::render_tooltip_icon('targeted_object_cache_purge', 'Leave this on. Refreshes only the pages OTTO changed instead of wiping your whole site\'s memory cache — safer/faster on large sites.'); ?></span>
1739 2024 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">
1740 2025 When enabled, only the updated posts are evicted from the object cache (recommended for large sites).
1741 2026 When disabled, a full <code>wp_cache_flush()</code> is used instead.
1742 2027 </span>
@@ -1807,11 +2092,11 @@
1807 2092 </div>
1808 2093
1809 2094 <?php
1810 2095 // Display success/error messages
1811 - if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') {
1812 - $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0;
1813 - $url = isset($_GET['url']) ? urldecode(sanitize_text_field(wp_unslash($_GET['url']))) : '';
2096 + if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2097 + $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2098 + $url = isset($_GET['url']) ? urldecode(sanitize_text_field(wp_unslash($_GET['url']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
1814 2099
1815 2100 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1816 2101 if (!empty($url)) {
1817 2102 echo '✅ <strong>Success!</strong> Cleared cache for URL: <code>' . esc_html($url) . '</code> (' . intval( $cleared_count ) . ' entries)';
@@ -1820,10 +2105,10 @@
1820 2105 }
1821 2106 echo '</p></div>';
1822 2107 }
1823 2108
1824 - if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') {
1825 - $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : 'An unknown error occurred.';
2109 + if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2110 + $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : 'An unknown error occurred.'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
1826 2111 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
1827 2112 echo '❌ <strong>Error:</strong> ' . esc_html($message);
1828 2113 echo '</p></div>';
1829 2114 }
@@ -1862,9 +2147,9 @@
1862 2147 <td>
1863 2148 <input type="url"
1864 2149 id="otto_cache_url"
1865 2150 name="otto_cache_url"
1866 - value="<?php echo isset($_GET['url']) ? esc_attr(urldecode(sanitize_text_field(wp_unslash($_GET['url'])))) : ''; ?>"
2151 + value="<?php echo isset($_GET['url']) ? esc_attr(urldecode(sanitize_text_field(wp_unslash($_GET['url'])))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce ?>"
1867 2152 class="regular-text"
1868 2153 placeholder="https://example.com/page/"
1869 2154 required />
1870 2155 <p class="description" style="color: var(--dashboard-text-secondary);">Enter the full URL of the page whose cache you want to clear.</p>
@@ -1935,9 +2220,10 @@
1935 2220 /**
1936 2221 * AJAX handler for saving hosting cache settings
1937 2222 */
1938 2223 public function ajax_save_hosting_cache_settings() {
1939 - if (!isset($_POST['hosting_cache_nonce']) || !wp_verify_nonce($_POST['hosting_cache_nonce'], 'metasync_hosting_cache_nonce')) {
2224 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2225 + if (!isset($_POST['hosting_cache_nonce']) || !wp_verify_nonce(wp_unslash($_POST['hosting_cache_nonce']), 'metasync_hosting_cache_nonce')) {
1940 2226 wp_send_json_error(array('message' => 'Security check failed'));
1941 2227 return;
1942 2228 }
1943 2229
@@ -1958,9 +2244,10 @@
1958 2244 /**
1959 2245 * AJAX handler: save object cache behaviour settings
1960 2246 */
1961 2247 public function ajax_save_object_cache_settings() {
1962 - if (!isset($_POST['object_cache_nonce']) || !wp_verify_nonce($_POST['object_cache_nonce'], 'metasync_object_cache_nonce')) {
2248 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2249 + if (!isset($_POST['object_cache_nonce']) || !wp_verify_nonce(wp_unslash($_POST['object_cache_nonce']), 'metasync_object_cache_nonce')) {
1963 2250 wp_send_json_error(array('message' => 'Security check failed'));
1964 2251 return;
1965 2252 }
1966 2253
@@ -1974,12 +2261,52 @@
1974 2261 wp_send_json_success(array('message' => 'Object cache settings saved'));
1975 2262 }
1976 2263
1977 2264 /**
2265 + * Get OTTO Cache TTL value in minutes from execution settings.
2266 + *
2267 + * @return int
2268 + */
2269 + private function get_otto_cache_ttl_minutes() {
2270 + $execution_settings = get_option('metasync_execution_settings', array());
2271 + return isset($execution_settings['otto_cache_ttl']) ? absint($execution_settings['otto_cache_ttl']) : 30;
2272 + }
2273 +
2274 + /**
2275 + * AJAX handler for saving OTTO Cache TTL
2276 + */
2277 + public function ajax_save_otto_cache_ttl() {
2278 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2279 + if (!isset($_POST['otto_cache_ttl_nonce']) || !wp_verify_nonce(wp_unslash($_POST['otto_cache_ttl_nonce']), 'metasync_otto_cache_ttl_nonce')) {
2280 + wp_send_json_error(array('message' => 'Security check failed.'));
2281 + return;
2282 + }
2283 +
2284 + if (!current_user_can('manage_options')) {
2285 + wp_send_json_error(array('message' => 'Insufficient permissions.'));
2286 + return;
2287 + }
2288 +
2289 + $ttl = isset($_POST['otto_cache_ttl']) ? absint($_POST['otto_cache_ttl']) : 30;
2290 +
2291 + if ($ttl < 30 || $ttl > 1440) {
2292 + wp_send_json_error(array('message' => sprintf('%s Cache TTL must be between 30 and 1440 minutes.', Metasync::get_whitelabel_otto_name())));
2293 + return;
2294 + }
2295 +
2296 + $settings = get_option('metasync_execution_settings', array());
2297 + $settings['otto_cache_ttl'] = $ttl;
2298 + update_option('metasync_execution_settings', $settings);
2299 +
2300 + wp_send_json_success(array('message' => sprintf('%s Cache TTL saved.', Metasync::get_whitelabel_otto_name())));
2301 + }
2302 +
2303 + /**
1978 2304 * admin_post handler: purge WP Engine and Kinsta hosting-level caches
1979 2305 */
1980 2306 public function handle_purge_hosting_cache() {
1981 - if (!isset($_POST['hosting_cache_purge_nonce']) || !wp_verify_nonce($_POST['hosting_cache_purge_nonce'], 'metasync_hosting_cache_purge_nonce')) {
2307 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2308 + if (!isset($_POST['hosting_cache_purge_nonce']) || !wp_verify_nonce(wp_unslash($_POST['hosting_cache_purge_nonce']), 'metasync_hosting_cache_purge_nonce')) {
1982 2309 wp_die('Security check failed');
1983 2310 }
1984 2311
1985 2312 if (!Metasync::current_user_has_plugin_access()) {
@@ -1999,9 +2326,9 @@
1999 2326 WpeCommon::purge_varnish_cache();
2000 2327 WpeCommon::purge_memcached();
2001 2328 $cleared[] = 'WP Engine';
2002 2329 } catch (Exception $e) {
2003 - error_log('MetaSync: WP Engine hosting cache purge failed - ' . $e->getMessage());
2330 + error_log('MetaSync: WP Engine hosting cache purge failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2004 2331 $failed[] = 'WP Engine';
2005 2332 }
2006 2333 } else {
2007 2334 $not_detected[] = 'WP Engine';
@@ -2014,9 +2341,9 @@
2014 2341 try {
2015 2342 KinstaCache::get_instance()->kinsta_cache_purge_full();
2016 2343 $cleared[] = 'Kinsta';
2017 2344 } catch (Exception $e) {
2018 - error_log('MetaSync: Kinsta hosting cache purge failed - ' . $e->getMessage());
2345 + error_log('MetaSync: Kinsta hosting cache purge failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2019 2346 $failed[] = 'Kinsta';
2020 2347 }
2021 2348 } else {
2022 2349 $not_detected[] = 'Kinsta';
@@ -2411,9 +2738,9 @@
2411 2738 # Initialize MetaSync API request class and trigger synchronization
2412 2739 (new Metasync_Sync_Requests())->SyncCustomerParams();
2413 2740 } catch (Exception $e) {
2414 2741 # Log any API request errors for debugging
2415 - error_log('Metasync API Error: ' . $e->getMessage());
2742 + error_log('Metasync API Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2416 2743 }
2417 2744 }
2418 2745
2419 2746 /*
@@ -2428,9 +2755,9 @@
2428 2755 # Initialize MetaSync API request class and trigger synchronization
2429 2756 (new Metasync_Sync_Requests())->SyncCustomerParams();
2430 2757 } catch (Exception $e) {
2431 2758 # Log any API request errors for debugging
2432 - error_log('Metasync API Error: ' . $e->getMessage());
2759 + error_log('Metasync API Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2433 2760 }
2434 2761 }
2435 2762
2436 2763 /**
@@ -2463,8 +2790,15 @@
2463 2790 // Handle form submissions
2464 2791 if (isset($_POST['metasync_media_optimization_nonce'])) {
2465 2792 check_admin_referer('metasync_save_media_optimization', 'metasync_media_optimization_nonce');
2466 2793
2794 + // Settings shape the whole site's conversion pipeline — restrict
2795 + // the save (and the reset) to site admins, not just anyone who can
2796 + // open the page via plugin_access_roles.
2797 + if (!current_user_can('manage_options')) {
2798 + wp_die(esc_html__('Sorry, you are not allowed to manage media optimization settings.', 'metasync'));
2799 + }
2800 +
2467 2801 // Handle reset to defaults
2468 2802 if (!empty($_POST['metasync_media_reset'])) {
2469 2803 $defaults = Metasync_Media_Settings::get_defaults();
2470 2804 Metasync_Media_Settings::save_settings($defaults);
@@ -2469,8 +2803,9 @@
2469 2803 $defaults = Metasync_Media_Settings::get_defaults();
2470 2804 Metasync_Media_Settings::save_settings($defaults);
2471 2805 $save_success = true;
2472 2806 } elseif (isset($_POST['metasync_media'])) {
2807 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Media_Settings::save_settings().
2473 2808 $input = wp_unslash($_POST['metasync_media']);
2474 2809 Metasync_Media_Settings::save_settings($input);
2475 2810 $save_success = true;
2476 2811 }
@@ -2502,8 +2837,15 @@
2502 2837 * Code Minification page callback
2503 2838 */
2504 2839 public function create_admin_code_minification_page()
2505 2840 {
2841 + // Settings shape the whole site's asset pipeline — restrict
2842 + // the save (and the reset) to site admins, not just anyone who can
2843 + // open the page via plugin_access_roles.
2844 + if (!empty($_POST) && !current_user_can('manage_options')) {
2845 + wp_die(esc_html__('Sorry, you are not allowed to manage code minification settings.', 'metasync'));
2846 + }
2847 +
2506 2848 $this->render_layout_open('Code Minification', 'code_minification', 'Minify CSS, JavaScript, and HTML to improve performance.');
2507 2849 // Load settings and compatibility classes
2508 2850 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-settings.php';
2509 2851 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-cache.php';
@@ -2520,8 +2862,9 @@
2520 2862 $defaults = Metasync_Minification_Settings::get_defaults();
2521 2863 Metasync_Minification_Settings::save_settings($defaults);
2522 2864 $save_success = true;
2523 2865 } elseif (isset($_POST['metasync_code_min'])) {
2866 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Minification_Settings::save_settings().
2524 2867 $input = (array) wp_unslash($_POST['metasync_code_min']);
2525 2868 Metasync_Minification_Settings::save_settings($input);
2526 2869 $save_success = true;
2527 2870 }
@@ -2554,11 +2897,40 @@
2554 2897 if (!$attachment_id) {
2555 2898 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2556 2899 }
2557 2900
2901 + // Ownership: under the replace strategy this permanently deletes the
2902 + // attachment's original file, so upload_files alone is not enough —
2903 + // the caller must be allowed to edit this specific attachment.
2904 + if (!current_user_can('edit_post', $attachment_id)) {
2905 + wp_send_json_error(__('Permission denied.', 'metasync'));
2906 + }
2907 +
2558 2908 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-settings.php';
2909 + require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-image-converter.php';
2559 2910 $settings = Metasync_Media_Settings::get_settings();
2560 2911
2912 + $file = get_attached_file($attachment_id);
2913 + $mime = get_post_mime_type($attachment_id);
2914 +
2915 + if (!$file || !file_exists($file)) {
2916 + wp_send_json_error(__('Optimization failed: file not found.', 'metasync'));
2917 + }
2918 +
2919 + $max_bytes = 10 * 1024 * 1024;
2920 + if (filesize($file) > $max_bytes) {
2921 + $size_mb = round(filesize($file) / 1024 / 1024, 1);
2922 + wp_send_json_error(sprintf(
2923 + /* translators: %s: file size in megabytes. */
2924 + __('Optimization skipped: file size (%s MB) exceeds the 10 MB safety limit to prevent memory issues.', 'metasync'),
2925 + $size_mb
2926 + ));
2927 + }
2928 +
2929 + if (!in_array($mime, ['image/jpeg', 'image/png'], true)) {
2930 + wp_send_json_error(__('Optimization failed: unsupported image format.', 'metasync'));
2931 + }
2932 +
2561 2933 $success = Metasync_Image_Converter::convert_attachment($attachment_id, $settings);
2562 2934
2563 2935 if ($success) {
2564 2936 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
@@ -2563,12 +2935,13 @@
2563 2935 if ($success) {
2564 2936 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
2565 2937 wp_send_json_success([
2566 2938 'status_html' => Metasync_Media_Library_List_Table::render_status_html($attachment_id),
2939 + 'can_revert' => Metasync_Image_Converter::can_revert($attachment_id),
2567 2940 ]);
2568 2941 }
2569 2942
2570 - wp_send_json_error(__('Optimization failed.', 'metasync'));
2943 + wp_send_json_error(__('Optimization failed: conversion could not be completed.', 'metasync'));
2571 2944 }
2572 2945
2573 2946 /**
2574 2947 * AJAX: Revert a single image.
@@ -2585,8 +2958,20 @@
2585 2958 if (!$attachment_id) {
2586 2959 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2587 2960 }
2588 2961
2962 + // Ownership: reverting deletes the attachment's converted files, so
2963 + // restrict it to users who may edit this specific attachment.
2964 + if (!current_user_can('edit_post', $attachment_id)) {
2965 + wp_send_json_error(__('Permission denied.', 'metasync'));
2966 + }
2967 +
2968 + // Replace-strategy conversions have no original to restore — reverting
2969 + // would delete the attachment's only file. Refuse up front, like bulk revert does.
2970 + if (!Metasync_Image_Converter::can_revert($attachment_id)) {
2971 + wp_send_json_error(__('This image cannot be reverted. The original file no longer exists (replace strategy).', 'metasync'));
2972 + }
2973 +
2589 2974 $success = Metasync_Image_Converter::revert_attachment($attachment_id);
2590 2975
2591 2976 if ($success) {
2592 2977 wp_send_json_success();
@@ -2638,8 +3023,15 @@
2638 3023 public function ajax_batch_progress()
2639 3024 {
2640 3025 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
2641 3026
3027 + // Read-only progress/stats polling — the same capability that can
3028 + // open the media optimization page is enough, but a nonce alone
3029 + // never was.
3030 + if (!current_user_can('upload_files')) {
3031 + wp_send_json_error(__('Permission denied.', 'metasync'));
3032 + }
3033 +
2642 3034 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-batch-optimizer.php';
2643 3035 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
2644 3036
2645 3037 $progress = Metasync_Media_Batch_Optimizer::get_progress();
@@ -2670,10 +3062,19 @@
2670 3062 $settings = Metasync_Media_Settings::get_settings();
2671 3063
2672 3064 $success = 0;
2673 3065 $failed = 0;
3066 + $denied = 0;
2674 3067
2675 3068 foreach ($ids as $id) {
3069 + // Per-attachment ownership: replace mode deletes originals, so
3070 + // upload_files alone must not allow converting images the caller
3071 + // does not own/cannot edit.
3072 + if (!current_user_can('edit_post', $id)) {
3073 + $denied++;
3074 + continue;
3075 + }
3076 +
2676 3077 if (Metasync_Image_Converter::convert_attachment($id, $settings)) {
2677 3078 $success++;
2678 3079 } else {
2679 3080 $failed++;
@@ -2682,12 +3083,81 @@
2682 3083
2683 3084 wp_send_json_success([
2684 3085 'success' => $success,
2685 3086 'failed' => $failed,
3087 + 'denied' => $denied,
2686 3088 ]);
2687 3089 }
2688 3090
2689 3091 /**
3092 + * AJAX: Bulk unoptimize (revert) selected images.
3093 + */
3094 + public function ajax_bulk_unoptimize_selected()
3095 + {
3096 + check_ajax_referer('metasync_media_opt_nonce', 'nonce');
3097 +
3098 + if (!current_user_can('upload_files')) {
3099 + wp_send_json_error(__('Permission denied.', 'metasync'));
3100 + }
3101 +
3102 + $ids = isset($_POST['ids']) ? array_map('absint', explode(',', sanitize_text_field(wp_unslash($_POST['ids'])))) : [];
3103 + $ids = array_filter($ids);
3104 +
3105 + if (empty($ids)) {
3106 + wp_send_json_error(__('No images selected.', 'metasync'));
3107 + }
3108 +
3109 + $success = 0;
3110 + $failed = 0;
3111 + $skipped = 0;
3112 + $denied = 0;
3113 + $errors = [];
3114 +
3115 + foreach ($ids as $id) {
3116 + // Per-attachment ownership: reverting deletes the attachment's
3117 + // converted files, so restrict it per image, not per role.
3118 + if (!current_user_can('edit_post', $id)) {
3119 + $denied++;
3120 + continue;
3121 + }
3122 +
3123 + $format = get_post_meta($id, '_metasync_converted_format', true);
3124 +
3125 + if (!$format) {
3126 + $skipped++;
3127 + continue;
3128 + }
3129 +
3130 + if (!Metasync_Image_Converter::can_revert($id)) {
3131 + $skipped++;
3132 + $file = get_attached_file($id);
3133 + $name = $file ? basename($file) : "ID {$id}";
3134 + /* translators: %s: attachment file name. */
3135 + $errors[] = sprintf(__('%s: skipped — original image unavailable.', 'metasync'), $name);
3136 + continue;
3137 + }
3138 +
3139 + if (Metasync_Image_Converter::revert_attachment($id)) {
3140 + $success++;
3141 + } else {
3142 + $failed++;
3143 + $file = get_attached_file($id);
3144 + $name = $file ? basename($file) : "ID {$id}";
3145 + /* translators: %s: attachment file name. */
3146 + $errors[] = sprintf(__('%s: revert failed (original file may not exist).', 'metasync'), $name);
3147 + }
3148 + }
3149 +
3150 + wp_send_json_success([
3151 + 'success' => $success,
3152 + 'failed' => $failed,
3153 + 'skipped' => $skipped,
3154 + 'denied' => $denied,
3155 + 'errors' => $errors,
3156 + ]);
3157 + }
3158 +
3159 + /**
2690 3160 * AJAX: Process one batch tick (browser-driven chaining).
2691 3161 */
2692 3162 public function ajax_process_batch_tick()
2693 3163 {
@@ -2706,8 +3176,49 @@
2706 3176 wp_send_json_success($progress);
2707 3177 }
2708 3178
2709 3179 /**
3180 + * AJAX: Delete an orphaned media record (attachment whose file is missing).
3181 + *
3182 + * Only deletes when the file is confirmed missing on disk, so valid
3183 + * attachments can never be removed through this endpoint.
3184 + */
3185 + public function ajax_delete_orphaned_image()
3186 + {
3187 + check_ajax_referer('metasync_media_opt_nonce', 'nonce');
3188 +
3189 + $attachment_id = isset($_POST['attachment_id']) ? absint($_POST['attachment_id']) : 0;
3190 + if (!$attachment_id) {
3191 + wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
3192 + }
3193 +
3194 + if (!current_user_can('delete_post', $attachment_id)) {
3195 + wp_send_json_error(__('Permission denied.', 'metasync'));
3196 + }
3197 +
3198 + if (get_post_type($attachment_id) !== 'attachment') {
3199 + wp_send_json_error(__('Not an attachment.', 'metasync'));
3200 + }
3201 +
3202 + require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
3203 +
3204 + // Guard: only orphaned records (missing file) may be deleted here.
3205 + if (!Metasync_Media_Library_List_Table::is_file_missing($attachment_id)) {
3206 + wp_send_json_error(__('The image file exists; refusing to delete a valid attachment.', 'metasync'));
3207 + }
3208 +
3209 + $deleted = wp_delete_attachment($attachment_id, true);
3210 +
3211 + if ($deleted) {
3212 + wp_send_json_success([
3213 + 'stats' => Metasync_Media_Library_List_Table::get_stats(),
3214 + ]);
3215 + }
3216 +
3217 + wp_send_json_error(__('Failed to delete the orphaned media record.', 'metasync'));
3218 + }
3219 +
3220 + /**
2710 3221 * Cron handler: Process batch optimization tick.
2711 3222 */
2712 3223 public function handle_media_batch_cron()
2713 3224 {
@@ -2717,8 +3228,86 @@
2717 3228 Metasync_Media_Batch_Optimizer::process_batch_tick();
2718 3229 }
2719 3230
2720 3231 /**
3232 + * AJAX: Start SEO Restore batch.
3233 + */
3234 + public function ajax_seo_restore_start()
3235 + {
3236 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3237 +
3238 + if (!current_user_can('manage_options')) {
3239 + wp_send_json_error(__('Permission denied.', 'metasync'));
3240 + }
3241 +
3242 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3243 +
3244 + $progress = Metasync_Seo_Restore::start_batch();
3245 + wp_send_json_success($progress);
3246 + }
3247 +
3248 + /**
3249 + * AJAX: Cancel SEO Restore batch.
3250 + */
3251 + public function ajax_seo_restore_cancel()
3252 + {
3253 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3254 +
3255 + if (!current_user_can('manage_options')) {
3256 + wp_send_json_error(__('Permission denied.', 'metasync'));
3257 + }
3258 +
3259 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3260 +
3261 + Metasync_Seo_Restore::cancel_batch();
3262 + wp_send_json_success();
3263 + }
3264 +
3265 + /**
3266 + * AJAX: Get SEO Restore batch progress.
3267 + */
3268 + public function ajax_seo_restore_progress()
3269 + {
3270 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3271 +
3272 + if (!current_user_can('manage_options')) {
3273 + wp_send_json_error(__('Permission denied.', 'metasync'));
3274 + }
3275 +
3276 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3277 +
3278 + $progress = Metasync_Seo_Restore::get_progress();
3279 + wp_send_json_success($progress);
3280 + }
3281 +
3282 + /**
3283 + * AJAX: Process one SEO Restore tick (browser-driven chaining).
3284 + */
3285 + public function ajax_seo_restore_process_tick()
3286 + {
3287 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3288 +
3289 + if (!current_user_can('manage_options')) {
3290 + wp_send_json_error(__('Permission denied.', 'metasync'));
3291 + }
3292 +
3293 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3294 +
3295 + $progress = Metasync_Seo_Restore::process_ajax_tick();
3296 + wp_send_json_success($progress);
3297 + }
3298 +
3299 + /**
3300 + * Cron handler: Process SEO Restore batch tick.
3301 + */
3302 + public function handle_seo_restore_cron()
3303 + {
3304 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3305 +
3306 + Metasync_Seo_Restore::process_batch_tick();
3307 + }
3308 +
3309 + /**
2721 3310 * Report Issue page callback
2722 3311 */
2723 3312 public function create_admin_report_issue_page()
2724 3313 {
@@ -2730,8 +3319,17 @@
2730 3319 * XML Sitemap page callback
2731 3320 */
2732 3321 public function create_admin_xml_sitemap_page()
2733 3322 {
3323 + // Every form action below mutates site-wide state — sitemap options,
3324 + // sitemap files on disk, robots.txt, other plugins' sitemap generators
3325 + // — so they are restricted to site admins, not just anyone who can
3326 + // open the page via plugin_access_roles. The nonce is CSRF
3327 + // protection, not authorization.
3328 + if (!empty($_POST) && !current_user_can('manage_options')) {
3329 + wp_die(esc_html__('Sorry, you are not allowed to manage sitemap settings.', 'metasync'));
3330 + }
3331 +
2734 3332 // Load sitemap generator class
2735 3333 require_once plugin_dir_path(dirname(__FILE__)) . 'sitemap/class-metasync-sitemap-generator.php';
2736 3334
2737 3335 $sitemap_generator = new Metasync_Sitemap_Generator();
@@ -2743,17 +3341,56 @@
2743 3341 } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) {
2744 3342 $active_tab = sanitize_text_field(wp_unslash($_POST['redirect_tab']));
2745 3343 }
2746 3344
3345 + // Handle Main Sitemap settings form submission
3346 + if (isset($_POST['metasync_sitemap_settings_nonce']) && isset($_POST['save_sitemap_settings'])) {
3347 + check_admin_referer('metasync_sitemap_settings_action', 'metasync_sitemap_settings_nonce');
3348 +
3349 + $sitemap_settings = [
3350 + '_configured' => true,
3351 + 'post_types' => array_map('sanitize_key', (array) ($_POST['sitemap_post_types'] ?? [])),
3352 + 'categories' => array_map('absint', (array) ($_POST['sitemap_categories'] ?? [])),
3353 + 'tags' => array_map('absint', (array) ($_POST['sitemap_tags'] ?? [])),
3354 + 'taxonomies' => array_map('sanitize_key', (array) ($_POST['sitemap_taxonomies'] ?? [])),
3355 + 'excluded_urls' => sanitize_textarea_field(wp_unslash($_POST['sitemap_excluded_urls'] ?? '')),
3356 + ];
3357 +
3358 + update_option('metasync_sitemap_settings', $sitemap_settings);
3359 +
3360 + // Regenerate sitemap with new content settings
3361 + $result = $sitemap_generator->generate_sitemap();
3362 + if (is_wp_error($result)) {
3363 + echo '<div class="notice notice-error"><p>' . esc_html(
3364 + /* translators: %s: error message. */
3365 + sprintf(__('Settings saved but sitemap generation failed: %s', 'metasync'), $result->get_error_message())
3366 + ) . '</p></div>';
3367 + } elseif (false === $result) {
3368 + echo '<div class="notice notice-error"><p>' . esc_html__('Settings saved but sitemap generation failed: the sitemap data could not be stored, so no sitemap is being served. Check your object cache and database write settings, then try again.', 'metasync') . '</p></div>';
3369 + } else {
3370 + echo '<div class="notice notice-success"><p>' . esc_html__('Sitemap content settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3371 + }
3372 + }
3373 +
2747 3374 // Handle News Sitemap settings form submission
2748 3375 if (isset($_POST['metasync_news_sitemap_nonce']) && isset($_POST['save_news_sitemap'])) {
2749 3376 check_admin_referer('metasync_news_sitemap_action', 'metasync_news_sitemap_nonce');
2750 3377
3378 + // Build generic taxonomy filters
3379 + $news_taxonomies = [];
3380 + if (!empty($_POST['news_taxonomies']) && is_array($_POST['news_taxonomies'])) {
3381 + foreach (wp_unslash($_POST['news_taxonomies']) as $tax_name => $term_ids) {
3382 + $news_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) wp_unslash($term_ids));
3383 + }
3384 + }
3385 +
2751 3386 $news_settings = [
2752 3387 'enabled' => isset($_POST['news_enabled']),
2753 3388 'post_types' => array_map('sanitize_key', (array) ($_POST['news_post_types'] ?? ['post'])),
2754 3389 'categories' => array_map('absint', (array) ($_POST['news_categories'] ?? [])),
2755 3390 'tags' => array_map('absint', (array) ($_POST['news_tags'] ?? [])),
3391 + 'taxonomies' => $news_taxonomies,
3392 + 'excluded_urls' => sanitize_textarea_field(wp_unslash($_POST['news_excluded_urls'] ?? '')),
2756 3393 'publication_name' => sanitize_text_field(wp_unslash($_POST['publication_name'] ?? '')),
2757 3394 'publication_language' => sanitize_text_field(wp_unslash($_POST['publication_language'] ?? '')),
2758 3395 ];
2759 3396
@@ -2763,13 +3400,14 @@
2763 3400 update_option('metasync_news_sitemap_settings', $news_settings);
2764 3401
2765 3402 if ($news_settings['enabled']) {
2766 3403 $sitemap_generator->generate_news_sitemap();
3404 + update_option('metasync_sitemap_last_generated', current_time('mysql'));
2767 3405 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
2768 3406 } else {
2769 3407 // Also remove physical file if it exists
2770 3408 if (file_exists(ABSPATH . 'news-sitemap.xml')) {
2771 - @unlink(ABSPATH . 'news-sitemap.xml');
3409 + @wp_delete_file(ABSPATH . 'news-sitemap.xml');
2772 3410 }
2773 3411 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
2774 3412 }
2775 3413 }
@@ -2777,12 +3415,26 @@
2777 3415 // Handle Video Sitemap settings form submission
2778 3416 if (isset($_POST['metasync_video_sitemap_nonce']) && isset($_POST['save_video_sitemap'])) {
2779 3417 check_admin_referer('metasync_video_sitemap_action', 'metasync_video_sitemap_nonce');
2780 3418
3419 + // Build generic taxonomy filters
3420 + $video_taxonomies = [];
3421 + if (!empty($_POST['video_taxonomies']) && is_array($_POST['video_taxonomies'])) {
3422 + foreach (wp_unslash($_POST['video_taxonomies']) as $tax_name => $term_ids) {
3423 + $video_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) wp_unslash($term_ids));
3424 + }
3425 + }
3426 +
2781 3427 $video_settings = [
2782 - 'enabled' => isset($_POST['video_enabled']),
2783 - 'post_types' => array_map('sanitize_key', (array) ($_POST['video_post_types'] ?? ['post', 'page'])),
2784 - 'auto_detect' => isset($_POST['auto_detect']),
3428 + 'enabled' => isset($_POST['video_enabled']),
3429 + 'post_types' => array_map('sanitize_key', (array) ($_POST['video_post_types'] ?? ['post', 'page'])),
3430 + 'auto_detect' => isset($_POST['auto_detect']),
3431 + 'taxonomies' => $video_taxonomies,
3432 + 'excluded_urls' => sanitize_textarea_field(wp_unslash($_POST['video_excluded_urls'] ?? '')),
3433 + // This array replaces the stored option wholesale, so every
3434 + // setting must be represented here or it is wiped on save.
3435 + 'video_url_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_url_meta_keys'] ?? '')),
3436 + 'video_thumbnail_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_thumbnail_meta_keys'] ?? '')),
2785 3437 ];
2786 3438
2787 3439 // Always invalidate old cache before saving new settings
2788 3440 delete_transient('metasync_vsm_' . md5('video-sitemap.xml'));
@@ -2790,13 +3442,14 @@
2790 3442 update_option('metasync_video_sitemap_settings', $video_settings);
2791 3443
2792 3444 if ($video_settings['enabled']) {
2793 3445 $sitemap_generator->generate_video_sitemap();
3446 + update_option('metasync_sitemap_last_generated', current_time('mysql'));
2794 3447 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
2795 3448 } else {
2796 3449 // Also remove physical file if it exists
2797 3450 if (file_exists(ABSPATH . 'video-sitemap.xml')) {
2798 - @unlink(ABSPATH . 'video-sitemap.xml');
3451 + @wp_delete_file(ABSPATH . 'video-sitemap.xml');
2799 3452 }
2800 3453 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
2801 3454 }
2802 3455 }
@@ -2839,8 +3492,17 @@
2839 3492 if ($sitemap_generator->generate_news_sitemap()) {
2840 3493 $extras[] = 'news';
2841 3494 }
2842 3495 }
3496 +
3497 + // Reclaim unreferenced memory between the news and video passes
3498 + // so the video generator starts with more headroom (they share
3499 + // one request). Each generator already releases its own object
3500 + // cache via clean_post_cache(), so no cache flush is needed here.
3501 + if (function_exists('gc_collect_cycles')) {
3502 + gc_collect_cycles();
3503 + }
3504 +
2843 3505 if (!empty($video_opts['enabled'])) {
2844 3506 if ($sitemap_generator->generate_video_sitemap()) {
2845 3507 $extras[] = 'video';
2846 3508 }
@@ -2845,17 +3507,30 @@
2845 3507 $extras[] = 'video';
2846 3508 }
2847 3509 }
2848 3510
3511 + // Same inter-pass GC before the main sitemap pass begins.
3512 + if (function_exists('gc_collect_cycles')) {
3513 + gc_collect_cycles();
3514 + }
3515 +
2849 3516 // Generate main sitemap (its index will include news/video since they now exist)
2850 3517 $result = $sitemap_generator->generate_sitemap();
2851 3518
2852 3519 if (is_wp_error($result)) {
2853 - echo '<div class="notice notice-error"><p>' . esc_html($result->get_error_message()) . '</p></div>';
3520 + $error_msg = $result->get_error_message();
3521 + error_log('[MetaSync] Sitemap generation failed: ' . $error_msg); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3522 + echo '<div class="notice notice-error"><p>' . esc_html(
3523 + /* translators: %s: error message. */
3524 + sprintf(__('Sitemap generation failed: %s', 'metasync'), $error_msg)
3525 + ) . '</p></div>';
3526 + } elseif (false === $result) {
3527 + echo '<div class="notice notice-error"><p>' . esc_html__('Sitemap generation failed: the sitemap data could not be stored, so no sitemap is being served. Check your object cache and database write settings, then try again.', 'metasync') . '</p></div>';
2854 3528 } else {
2855 3529 $message = esc_html__('Sitemap generated successfully!', 'metasync');
2856 3530 if (!empty($extras)) {
2857 3531 $message .= ' ' . sprintf(
3532 + /* translators: %s: list of sitemap types. */
2858 3533 esc_html__('Also generated %s sitemap(s).', 'metasync'),
2859 3534 implode(' & ', $extras)
2860 3535 );
2861 3536 }
@@ -2884,8 +3559,38 @@
2884 3559 echo '<div class="notice notice-success"><p>' . esc_html__('Auto-update enabled!', 'metasync') . '</p></div>';
2885 3560 } elseif (isset($_POST['disable_auto_update'])) {
2886 3561 update_option('metasync_sitemap_auto_update', false);
2887 3562 echo '<div class="notice notice-success"><p>' . esc_html__('Auto-update disabled!', 'metasync') . '</p></div>';
3563 + } elseif (isset($_POST['delete_general_sitemap'])) {
3564 + $deleted = $sitemap_generator->delete_sitemap('general');
3565 +
3566 + if ($deleted) {
3567 + // Disable auto-update only when the general sitemap is removed
3568 + update_option('metasync_sitemap_auto_update', false);
3569 + // Re-enable WP core sitemap only if no other MetaSync sitemaps remain
3570 + if (!$sitemap_generator->sitemap_exists()) {
3571 + delete_option('metasync_disable_wp_sitemap');
3572 + }
3573 + echo '<div class="notice notice-success"><p>' . esc_html__('General sitemap deleted successfully!', 'metasync') . '</p></div>';
3574 + } else {
3575 + echo '<div class="notice notice-error"><p>' . esc_html__('Failed to delete general sitemap. The files may not exist or are not writable.', 'metasync') . '</p></div>';
3576 + }
3577 + } elseif (isset($_POST['delete_news_sitemap'])) {
3578 + $deleted = $sitemap_generator->delete_sitemap('news');
3579 +
3580 + if ($deleted) {
3581 + echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap deleted successfully!', 'metasync') . '</p></div>';
3582 + } else {
3583 + echo '<div class="notice notice-error"><p>' . esc_html__('Failed to delete news sitemap. The file may not exist or is not writable.', 'metasync') . '</p></div>';
3584 + }
3585 + } elseif (isset($_POST['delete_video_sitemap'])) {
3586 + $deleted = $sitemap_generator->delete_sitemap('video');
3587 +
3588 + if ($deleted) {
3589 + echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap deleted successfully!', 'metasync') . '</p></div>';
3590 + } else {
3591 + echo '<div class="notice notice-error"><p>' . esc_html__('Failed to delete video sitemap. The file may not exist or is not writable.', 'metasync') . '</p></div>';
3592 + }
2888 3593 } elseif (isset($_POST['delete_sitemap'])) {
2889 3594 // Delete all sitemaps: main + news + video (handled by delete_sitemap)
2890 3595 $deleted = $sitemap_generator->delete_sitemap();
2891 3596
@@ -2891,8 +3596,10 @@
2891 3596
2892 3597 if ($deleted) {
2893 3598 // Also disable auto-update when deleting
2894 3599 update_option('metasync_sitemap_auto_update', false);
3600 + // Re-enable WP core sitemap so the site isn't left with zero sitemaps
3601 + delete_option('metasync_disable_wp_sitemap');
2895 3602 echo '<div class="notice notice-success"><p>' . esc_html__('All sitemaps deleted successfully!', 'metasync') . '</p></div>';
2896 3603 } else {
2897 3604 echo '<div class="notice notice-error"><p>' . esc_html__('Failed to delete sitemaps. The files may not exist or are not writable.', 'metasync') . '</p></div>';
2898 3605 }
@@ -2914,8 +3621,17 @@
2914 3621 $last_generated = $sitemap_generator->get_last_generated_time();
2915 3622 $auto_update_enabled = get_option('metasync_sitemap_auto_update', false);
2916 3623 $active_sitemap_plugins = $sitemap_generator->check_active_sitemap_plugins();
2917 3624
3625 + // Main sitemap content settings
3626 + $sitemap_settings = get_option('metasync_sitemap_settings', [
3627 + 'post_types' => [],
3628 + 'categories' => [],
3629 + 'tags' => [],
3630 + 'taxonomies' => [],
3631 + 'excluded_urls' => '',
3632 + ]);
3633 +
2918 3634 // News and video sitemap settings for tabs
2919 3635 $news_settings = get_option('metasync_news_sitemap_settings', [
2920 3636 'enabled' => false,
2921 3637 'post_types' => ['post'],
@@ -2920,15 +3636,19 @@
2920 3636 'enabled' => false,
2921 3637 'post_types' => ['post'],
2922 3638 'categories' => [],
2923 3639 'tags' => [],
3640 + 'taxonomies' => [],
3641 + 'excluded_urls' => '',
2924 3642 'publication_name' => '',
2925 3643 'publication_language' => '',
2926 3644 ]);
2927 3645 $video_settings = get_option('metasync_video_sitemap_settings', [
2928 - 'enabled' => false,
2929 - 'post_types' => ['post', 'page'],
2930 - 'auto_detect' => true,
3646 + 'enabled' => false,
3647 + 'post_types' => ['post', 'page'],
3648 + 'auto_detect' => true,
3649 + 'taxonomies' => [],
3650 + 'excluded_urls' => '',
2931 3651 ]);
2932 3652
2933 3653 // Load view
2934 3654 require_once plugin_dir_path(dirname(__FILE__)) . 'views/metasync-xml-sitemap.php';
@@ -2999,47 +3719,8 @@
2999 3719 Metasync_Admin_Pages::get_instance($this)->create_admin_breadcrumbs_page();
3000 3720 }
3001 3721
3002 3722 /**
3003 - * Google Instant Index Setting page callback
3004 - */
3005 - public function create_admin_google_instant_index_page()
3006 - {
3007 - $this->render_layout_open('Instant Indexing', 'instant_index', 'Submit URLs to Google for instant indexing via the Indexing API.');
3008 -
3009 - // Render shared Google Index credentials section
3010 - if (!function_exists('google_index_direct')) {
3011 - if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) {
3012 - require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
3013 - } else {
3014 - error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php');
3015 - return;
3016 - }
3017 - }
3018 - $google_index = google_index_direct();
3019 - $service_info = $google_index->get_service_account_info();
3020 - $is_configured = !isset($service_info['error']);
3021 -
3022 - $saved_json_display = $is_configured ? $google_index->get_redacted_config_json() : '';
3023 -
3024 - include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-index-api-settings.php';
3025 -
3026 - // Render post types selection with save form
3027 - $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
3028 - $post_types_settings = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
3029 - ?>
3030 - <form method="POST" action="">
3031 - <?php include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-instant-post-types.php'; ?>
3032 - <div class="dashboard-card" style="padding: 20px;">
3033 - <?php submit_button('Save Post Types', 'primary', 'submit', false, array('class' => 'button button-primary')); ?>
3034 - </div>
3035 - </form>
3036 - <?php
3037 -
3038 - $this->render_layout_close();
3039 - }
3040 -
3041 - /**
3042 3723 * Google Console page callback
3043 3724 */
3044 3725 public function create_admin_google_console_page()
3045 3726 {
@@ -3062,16 +3743,8 @@
3062 3743 $this->render_layout_close();
3063 3744 }
3064 3745
3065 3746 /**
3066 - * General Options page callback
3067 - */
3068 - public function create_admin_optimal_settings_page()
3069 - {
3070 - Metasync_Admin_Pages::get_instance($this)->create_admin_optimal_settings_page();
3071 - }
3072 -
3073 - /**
3074 3747 * Global Options page callback
3075 3748 */
3076 3749 public function create_admin_global_settings_page()
3077 3750 {
@@ -3103,16 +3776,8 @@
3103 3776 Metasync_Admin_Pages::get_instance($this)->create_admin_seo_controls_page();
3104 3777 }
3105 3778
3106 3779 /**
3107 - * Site Optimal Settings page callback
3108 - */
3109 - public function optimization_settings_options()
3110 - {
3111 - Metasync_Admin_Pages::get_instance($this)->optimization_settings_options();
3112 - }
3113 -
3114 - /**
3115 3780 * redirection page callback with tabs
3116 3781 */
3117 3782 public function create_admin_redirections_page()
3118 3783 {
@@ -3139,10 +3804,11 @@
3139 3804 }
3140 3805 delete_transient( Metasync_CPU_Monitor::DEFER_NOTICE_TRANSIENT );
3141 3806 echo '<div class="notice notice-warning is-dismissible"><p>';
3142 3807 printf(
3143 - /* translators: 1: current load, 2: threshold, 3: core count */
3144 - esc_html__( 'MetaSync: Batch processing was deferred — server CPU load (%1$s) exceeded the threshold (%2$s on %3$s cores). Processing will resume automatically.', 'metasync' ),
3808 + /* translators: 1: plugin name, 2: current load, 3: threshold, 4: core count */
3809 + esc_html__( '%1$s: Batch processing was deferred — server CPU load (%2$s) exceeded the threshold (%3$s on %4$s cores). Processing will resume automatically.', 'metasync' ),
3810 + esc_html( Metasync::get_effective_plugin_name() ),
3145 3811 esc_html( $data['load'] ),
3146 3812 esc_html( $data['threshold'] ),
3147 3813 esc_html( $data['cores'] )
3148 3814 );
@@ -3160,9 +3826,10 @@
3160 3826 if (!get_transient('metasync_llms_conflict')) {
3161 3827 return;
3162 3828 }
3163 3829 echo '<div class="notice notice-info is-dismissible"><p>';
3164 - echo esc_html__('Note: Another SEO plugin (Yoast, Rank Math, or AIOSEO) may also be generating /llms.txt. MetaSync\'s version takes priority when enabled.', 'metasync');
3830 + /* translators: %s: effective plugin name (whitelabel-aware). */
3831 + echo esc_html(sprintf(__('Note: Another SEO plugin (Yoast, Rank Math, or AIOSEO) may also be generating /llms.txt. %s\'s version takes priority when enabled.', 'metasync'), Metasync::get_effective_plugin_name()));
3165 3832 echo '</p></div>';
3166 3833 }
3167 3834
3168 3835 /**
@@ -3231,8 +3898,17 @@
3231 3898 public function ajax_restore_robots_backup()
3232 3899 {
3233 3900 Metasync_Admin_Ajax::instance()->ajax_restore_robots_backup();
3234 3901 }
3902 +
3903 + /**
3904 + * AJAX handler to fetch a paginated page of robots.txt backups
3905 + */
3906 + public function ajax_get_robots_backups()
3907 + {
3908 + Metasync_Admin_Ajax::instance()->ajax_get_robots_backups();
3909 + }
3910 +
3235 3911 public function ajax_create_redirect_from_404()
3236 3912 {
3237 3913 Metasync_Admin_Ajax::instance()->ajax_create_redirect_from_404();
3238 3914 }
@@ -3344,17 +4020,17 @@
3344 4020 return;
3345 4021 }
3346 4022
3347 4023 // Get pagination parameters
3348 - $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
3349 - $per_page = 10;
4024 + $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
4025 + $per_page = Metasync_Per_Page_Helper::resolve('sync_log', 10);
3350 4026 $offset = ($page - 1) * $per_page;
3351 4027
3352 4028 // Get filters
3353 4029 $filters = [
3354 4030 // UI exposes date_range and status only. We compute date_from/date_to based on date_range
3355 - 'date_range' => isset($_GET['date_range']) ? sanitize_text_field(wp_unslash($_GET['date_range'])) : '',
3356 - 'status' => isset($_GET['status']) ? sanitize_text_field(wp_unslash($_GET['status'])) : '',
4031 + 'date_range' => isset($_GET['date_range']) ? sanitize_text_field(wp_unslash($_GET['date_range'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
4032 + 'status' => isset($_GET['status']) ? sanitize_text_field(wp_unslash($_GET['status'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
3357 4033 ];
3358 4034
3359 4035 // Map date_range to concrete date_from/date_to for DB queries
3360 4036 $date_range = $filters['date_range'];
@@ -3363,28 +4039,28 @@
3363 4039 $date_to = '';
3364 4040
3365 4041 if (!empty($date_range)) {
3366 4042 // End boundary is now by default
3367 - $date_to = date('Y-m-d H:i:s', $wp_now_ts);
4043 + $date_to = gmdate('Y-m-d H:i:s', $wp_now_ts);
3368 4044
3369 4045 if ($date_range === 'today') {
3370 4046 $start_ts = strtotime('today', $wp_now_ts);
3371 - $date_from = date('Y-m-d H:i:s', $start_ts);
4047 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3372 4048 } elseif ($date_range === 'yesterday') {
3373 4049 $start_ts = strtotime('yesterday', $wp_now_ts);
3374 4050 $end_ts = strtotime('today', $wp_now_ts) - 1; // end of yesterday
3375 - $date_from = date('Y-m-d H:i:s', $start_ts);
3376 - $date_to = date('Y-m-d H:i:s', $end_ts);
4051 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
4052 + $date_to = gmdate('Y-m-d H:i:s', $end_ts);
3377 4053 } elseif ($date_range === 'this_week') {
3378 4054 $start_of_week = (int) get_option('start_of_week', 1); // 0=Sun, 1=Mon
3379 - $day_of_week = (int) date('w', $wp_now_ts); // 0=Sun..6=Sat
4055 + $day_of_week = (int) gmdate('w', $wp_now_ts); // 0=Sun..6=Sat
3380 4056 // Convert start_of_week to PHP's 0..6 where 0=Sunday
3381 4057 $delta_days = ($day_of_week - $start_of_week + 7) % 7;
3382 4058 $start_ts = strtotime('-' . $delta_days . ' days', strtotime('today', $wp_now_ts));
3383 - $date_from = date('Y-m-d H:i:s', $start_ts);
4059 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3384 4060 } elseif ($date_range === 'this_month') {
3385 - $start_ts = strtotime(date('Y-m-01 00:00:00', $wp_now_ts));
3386 - $date_from = date('Y-m-d H:i:s', $start_ts);
4061 + $start_ts = strtotime(gmdate('Y-m-01 00:00:00', $wp_now_ts));
4062 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3387 4063 } elseif ($date_range === 'all') {
3388 4064 // no bounds
3389 4065 }
3390 4066 }
@@ -3411,12 +4087,24 @@
3411 4087 ?>
3412 4088 <div class="dashboard-card">
3413 4089 <div class="sync-log-header">
3414 4090 <div class="sync-log-title-section">
3415 - <h2>Changes Log</h2>
3416 - <p style="color: var(--dashboard-text-secondary); margin-bottom: 0;">
3417 - Recent content synchronizations from external tools.
3418 - <span style="margin-left:8px; font-size:12px; opacity:.75;">Records are automatically removed after 90 days.</span>
4091 + <?php
4092 + // The page title and description are already rendered by
4093 + // render_layout_open() above. Repeating them here printed
4094 + // "Changes Log" twice on screen, so the card keeps only the
4095 + // retention hint — the one piece of copy the layout header
4096 + // does not carry.
4097 + ?>
4098 + <p style="color: var(--dashboard-text-secondary); margin-top: 0; margin-bottom: 0;">
4099 + <?php
4100 + // No opacity here: this used to be a de-emphasised
4101 + // aside trailing a full-contrast sentence, but it is
4102 + // now the card's only copy. At 12px/.75 it composites
4103 + // to 4.28:1 on the card background, under the 4.5:1
4104 + // AA floor; at full strength it reads 6.52:1.
4105 + ?>
4106 + <span style="font-size:13px;">Records are automatically removed after 90 days.</span>
3419 4107 </p>
3420 4108 </div>
3421 4109
3422 4110 <!-- Filters + Clear Log button - Right aligned -->
@@ -3425,10 +4113,10 @@
3425 4113 style="background:#dc3545;color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:13px;"
3426 4114 data-nonce="<?php echo esc_attr(wp_create_nonce('metasync_clear_sync_log')); ?>">
3427 4115 🗑 Clear Log
3428 4116 </button>
3429 - <form method="get" class="sync-filters-form" onchange="this.submit()">
3430 - <input type="hidden" name="page" value="<?php echo esc_attr($_GET['page']); ?>">
4117 + <form method="get" class="sync-filters-form" onchange="this.submit()" style="display:flex;flex-direction:row;align-items:center;gap:12px;flex-wrap:nowrap;">
4118 + <input type="hidden" name="page" value="<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>">
3431 4119
3432 4120 <select name="date_range" class="sync-filter-select">
3433 4121 <option value="all" <?php selected($filters['date_range'] ?? 'all', 'all'); ?>> All Time</option>
3434 4122 <option value="today" <?php selected($filters['date_range'] ?? '', 'today'); ?>>Today</option>
@@ -3450,9 +4138,9 @@
3450 4138 <div class="sync-log-list">
3451 4139 <?php if (empty($sync_records)): ?>
3452 4140 <div class="sync-log-empty">
3453 4141 <div class="sync-log-empty-icon"><span class="dashicons dashicons-media-default" style="font-size:48px;width:48px;height:48px;color:var(--dashboard-text-secondary);"></span></div>
3454 - <h3>No sync records found</h3>
4142 + <h2>No sync records found</h2>
3455 4143 <p>Sync records will appear here when content/pages receive new updates.</p>
3456 4144 </div>
3457 4145 <?php else: ?>
3458 4146 <?php foreach ($sync_records as $record): ?>
@@ -3477,19 +4165,20 @@
3477 4165 </div>
3478 4166 </div>
3479 4167
3480 4168 <div class="sync-log-status" style="display:flex;align-items:center;gap:8px;">
3481 - <?php if ($record->status === 'published' || $record->status === 'publish'): ?>
3482 - <span class="sync-status-badge sync-status-published">
3483 - <span class="sync-status-icon">✓</span>
3484 - Published
3485 - </span>
3486 - <?php else: ?>
3487 - <span class="sync-status-badge sync-status-draft">
3488 - <span class="sync-status-icon">i</span>
3489 - Draft
3490 - </span>
3491 - <?php endif; ?>
4169 + <?php
4170 + $st = (string) $record->status;
4171 + $b_label = ucfirst($st); $b_bg = '#6b7280'; $b_icon = 'dashicons-info-outline';
4172 + if ($st === 'published' || $st === 'publish' || $st === 'success' || $st === 'partial') { $b_label = 'Published'; $b_bg = '#16a34a'; $b_icon = 'dashicons-yes'; }
4173 + elseif ($st === 'updated') { $b_label = 'Updated'; $b_bg = '#0d9488'; $b_icon = 'dashicons-update'; }
4174 + elseif ($st === 'failed' || $st === 'conflict' || $st === 'locked') { $b_label = 'Not imported'; $b_bg = '#64748b'; $b_icon = 'dashicons-minus'; }
4175 + elseif ($st === 'draft') { $b_label = 'Draft'; $b_bg = '#6b7280'; $b_icon = 'dashicons-info-outline'; }
4176 + ?>
4177 + <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;">
4178 + <span class="dashicons <?php echo esc_attr($b_icon); ?>" style="font-size:14px;width:14px;height:14px;line-height:14px;"></span>
4179 + <?php echo esc_html($b_label); ?>
4180 + </span>
3492 4181 <?php if ($record->source === 'MCP Client'): ?>
3493 4182 <button type="button"
3494 4183 class="metasync-rollback-btn"
3495 4184 data-id="<?php echo esc_attr($record->id); ?>"
@@ -3505,30 +4194,32 @@
3505 4194 <?php endif; ?>
3506 4195 </div>
3507 4196
3508 4197 <!-- Pagination -->
3509 - <?php if ($total_pages > 1): ?>
3510 - <div class="sync-log-pagination">
3511 - <div class="sync-log-pagination-info">
3512 - Total records: <?php echo intval( $total_records ); ?> | Showing <?php echo intval( $offset ) + 1; ?>-<?php echo intval( min($offset + $per_page, $total_records) ); ?>
3513 - </div>
4198 + <div class="sync-log-pagination">
4199 + <div class="sync-log-pagination-info">
4200 + Total records: <?php echo intval( $total_records ); ?><?php if ($total_records > 0): ?> | Showing <?php echo intval( $offset ) + 1; ?>-<?php echo intval( min($offset + $per_page, $total_records) ); ?><?php endif; ?>
4201 + <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper returns a complete escaped selector element.
4202 + echo Metasync_Per_Page_Helper::render_selector('sync_log', absint($per_page)); ?>
4203 + </div>
3514 4204
4205 + <?php if ($total_pages > 1): ?>
3515 4206 <div class="sync-log-pagination-controls">
3516 4207 <?php if ($page > 1): ?>
3517 - <a href="?page=<?php echo esc_attr($_GET['page']); ?>&paged=<?php echo intval( $page ) - 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">‹</a>
4208 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $page ) - 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">‹</a>
3518 4209 <?php endif; ?>
3519 4210
3520 4211 <?php for ($i = max(1, $page - 2); $i <= min($total_pages, $page + 2); $i++): ?>
3521 - <a href="?page=<?php echo esc_attr($_GET['page']); ?>&paged=<?php echo intval( $i ); ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>"
4212 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $i ); ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>"
3522 4213 class="sync-pagination-btn <?php echo $i === $page ? 'active' : ''; ?>"><?php echo intval( $i ); ?></a>
3523 4214 <?php endfor; ?>
3524 4215
3525 4216 <?php if ($page < $total_pages): ?>
3526 - <a href="?page=<?php echo esc_attr($_GET['page']); ?>&paged=<?php echo intval( $page ) + 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">›</a>
4217 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $page ) + 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">›</a>
3527 4218 <?php endif; ?>
3528 4219 </div>
3529 - </div>
3530 - <?php endif; ?>
4220 + <?php endif; ?>
4221 + </div>
3531 4222 </div>
3532 4223 <?php $this->render_layout_close(); ?>
3533 4224
3534 4225 <script>
@@ -3609,8 +4300,19 @@
3609 4300 if (!empty($value)) {
3610 4301 $query_parts[] = $key . '=' . urlencode($value);
3611 4302 }
3612 4303 }
4304 +
4305 + // Preserve the user-selected results-per-page value across page
4306 + // navigation so a non-default page size survives clicking a page link.
4307 + $per_page_key = Metasync_Per_Page_Helper::request_key('sync_log');
4308 + if (isset($_GET[$per_page_key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4309 + $per_page = (int) $_GET[$per_page_key]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4310 + if (in_array($per_page, Metasync_Per_Page_Helper::allowed_values(), true)) {
4311 + $query_parts[] = $per_page_key . '=' . $per_page;
4312 + }
4313 + }
4314 +
3613 4315 return !empty($query_parts) ? '&' . implode('&', $query_parts) : '';
3614 4316 }
3615 4317
3616 4318 /**
@@ -3784,18 +4486,8 @@
3784 4486 {
3785 4487 Metasync_Connect_Manager::instance()->handle_session_management_early();
3786 4488 }
3787 4489
3788 - /**
3789 - * @deprecated 2.5.12 Use Metasync_Auth_Manager instead of sessions for authentication
3790 - */
3791 - private function safe_session_start() {
3792 - // This method is deprecated and no longer used
3793 - // Authentication now uses Metasync_Auth_Manager with WordPress transients and user meta
3794 - _deprecated_function(__METHOD__, '2.5.12', 'Metasync_Auth_Manager');
3795 - return Metasync_Session_Helper::safe_start();
3796 - }
3797 -
3798 4490 private function handle_whitelabel_session_logic()
3799 4491 {
3800 4492 Metasync_Connect_Manager::instance()->handle_whitelabel_session_logic();
3801 4493 }
@@ -3841,8 +4533,9 @@
3841 4533 $stats = Metasync_CPU_Monitor::get_stats();
3842 4534 $per_core_threshold = Metasync_CPU_Monitor::get_per_core_threshold();
3843 4535 $cores = Metasync_CPU_Monitor::get_cpu_core_count();
3844 4536 $effective_threshold = Metasync_CPU_Monitor::get_effective_threshold();
4537 + $detection_reliable = Metasync_CPU_Monitor::is_core_detection_reliable();
3845 4538 ?>
3846 4539 <div style="background: var(--dashboard-card-bg); padding: 20px; border-radius: 8px;">
3847 4540 <!-- CPU Cores Detected -->
3848 4541 <div style="margin-bottom: 24px;">
@@ -3849,12 +4542,20 @@
3849 4542 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
3850 4543 CPU Cores Detected
3851 4544 </label>
3852 4545 <div style="padding: 10px 12px; background: var(--dashboard-input-bg); border: 1px solid var(--dashboard-border); border-radius: 6px; color: var(--dashboard-text-secondary);">
3853 - <strong><?php echo intval($cores); ?></strong> core<?php echo $cores !== 1 ? 's' : ''; ?>
4546 + <?php if ($detection_reliable) : ?>
4547 + <strong><?php echo intval($cores); ?></strong> core<?php echo $cores !== 1 ? 's' : ''; ?>
4548 + <?php else : ?>
4549 + <strong>Not detected</strong>
4550 + <?php endif; ?>
3854 4551 </div>
3855 4552 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
3856 - Automatically detected on this system.
4553 + <?php if ($detection_reliable) : ?>
4554 + Automatically detected on this system.
4555 + <?php else : ?>
4556 + Core detection is not available on this hosting environment.
4557 + <?php endif; ?>
3857 4558 </p>
3858 4559 </div>
3859 4560
3860 4561 <!-- Per-Core Load Threshold -->
@@ -3860,8 +4561,9 @@
3860 4561 <!-- Per-Core Load Threshold -->
3861 4562 <div style="margin-bottom: 24px;">
3862 4563 <label for="cpu_load_per_core_threshold" style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
3863 4564 Per-Core Load Threshold
4565 + <?php Metasync::render_tooltip_icon('cpu_per_core_load_threshold', 'How busy each CPU core can get before the plugin pauses background SEO syncing. Lower = more cautious. Most sites leave this at 2.0.'); ?>
3864 4566 </label>
3865 4567 <input type="number"
3866 4568 id="cpu_load_per_core_threshold"
3867 4569 name="metasync_options[performance][cpu_load_per_core_threshold]"
@@ -3881,9 +4583,9 @@
3881 4583 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
3882 4584 Effective Threshold
3883 4585 </label>
3884 4586 <div style="padding: 10px 12px; background: var(--dashboard-input-bg); border: 1px solid var(--dashboard-border); border-radius: 6px; color: var(--dashboard-text-secondary);">
3885 - <strong id="effective_threshold_value"><?php echo round($effective_threshold, 2); ?></strong>
4587 + <strong id="effective_threshold_value"><?php echo esc_html((string) round($effective_threshold, 2)); ?></strong>
3886 4588 </div>
3887 4589 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
3888 4590 Calculated as: cores × per-core threshold
3889 4591 </p>
@@ -3901,13 +4603,13 @@
3901 4603 <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo intval($stats['deferrals']); ?></div>
3902 4604 </div>
3903 4605 <div>
3904 4606 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Max Load Observed</div>
3905 - <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo round($stats['max_load'], 2); ?></div>
4607 + <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo esc_html((string) round($stats['max_load'], 2)); ?></div>
3906 4608 </div>
3907 4609 <div>
3908 4610 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Average Load</div>
3909 - <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo round($stats['avg_load'], 2); ?></div>
4611 + <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo esc_html((string) round($stats['avg_load'], 2)); ?></div>
3910 4612 </div>
3911 4613 </div>
3912 4614 </div>
3913 4615
@@ -4413,88 +5115,8 @@
4413 5115
4414 5116 /**
4415 5117 * Get the settings option array and print one of its values
4416 5118 */
4417 - public function no_index_posts_callback()
4418 - {
4419 - Metasync_Settings_Fields::instance()->no_index_posts_callback();
4420 - }
4421 -
4422 - /**
4423 - * Get the settings option array and print one of its values
4424 - */
4425 - public function no_follow_links_callback()
4426 - {
4427 - Metasync_Settings_Fields::instance()->no_follow_links_callback();
4428 - }
4429 -
4430 - /**
4431 - * Get the settings option array and print one of its values
4432 - */
4433 - public function open_external_links_callback()
4434 - {
4435 - Metasync_Settings_Fields::instance()->open_external_links_callback();
4436 - }
4437 -
4438 - /**
4439 - * Get the settings option array and print one of its values
4440 - */
4441 - public function add_alt_image_tags_callback()
4442 - {
4443 - Metasync_Settings_Fields::instance()->add_alt_image_tags_callback();
4444 - }
4445 -
4446 - /**
4447 - * Get the settings option array and print one of its values
4448 - */
4449 - public function add_title_image_tags_callback()
4450 - {
4451 - Metasync_Settings_Fields::instance()->add_title_image_tags_callback();
4452 - }
4453 -
4454 - /**
4455 - * Get the settings option array and print one of its values
4456 - */
4457 - public function site_type_callback()
4458 - {
4459 - Metasync_Settings_Fields::instance()->site_type_callback();
4460 - }
4461 -
4462 - /**
4463 - * Get the settings option array and print one of its values
4464 - */
4465 - public function site_business_type_callback()
4466 - {
4467 - Metasync_Settings_Fields::instance()->site_business_type_callback();
4468 - }
4469 -
4470 - /**
4471 - * Get the settings option array and print one of its values
4472 - */
4473 - public function site_company_name_callback()
4474 - {
4475 - Metasync_Settings_Fields::instance()->site_company_name_callback();
4476 - }
4477 -
4478 - /**
4479 - * Get the settings option array and print one of its values
4480 - */
4481 - public function site_google_logo_callback()
4482 - {
4483 - Metasync_Settings_Fields::instance()->site_google_logo_callback();
4484 - }
4485 -
4486 - /**
4487 - * Get the settings option array and print one of its values
4488 - */
4489 - public function site_social_share_image_callback()
4490 - {
4491 - Metasync_Settings_Fields::instance()->site_social_share_image_callback();
4492 - }
4493 -
4494 - /**
4495 - * Get the settings option array and print one of its values
4496 - */
4497 5119 public function common_robot_meta_tags_callback()
4498 5120 {
4499 5121 Metasync_Settings_Fields::instance()->common_robot_meta_tags_callback();
4500 5122 }
@@ -4671,13 +5293,11 @@
4671 5293 $current_permalink_structure = get_option('permalink_structure');
4672 5294
4673 5295 # Get the plugin name using centralized method
4674 5296 $plugin_name = Metasync::get_effective_plugin_name();
4675 - $current_rewrite_rules = get_option('rewrite_rules');
5297 +
4676 5298 # Check if the current permalink structure is set to "Plain"
4677 - if (($current_permalink_structure == '/%post_id%/' || $current_permalink_structure == '') && $current_rewrite_rules == '') {
4678 -
4679 - # Show admin notice with plugin name included in the message
5299 + if ($current_permalink_structure === '/%post_id%/' || $current_permalink_structure === '') {
4680 5300 printf(
4681 5301 '<div class="notice notice-error is-dismissible">
4682 5302 <p>
4683 5303 <b>Warning from %s</b><br>
@@ -4687,9 +5307,8 @@
4687 5307 </div>',
4688 5308 esc_html($plugin_name)
4689 5309 );
4690 5310 }
4691 - flush_rewrite_rules();
4692 5311 }
4693 5312
4694 5313 /**
4695 5314 * Show a one-time admin notice when a page builder is detected but the
@@ -4695,8 +5314,14 @@
4695 5314 * Show a one-time admin notice when a page builder is detected but the
4696 5315 * "Default Page Builder" setting has never been explicitly saved.
4697 5316 */
4698 5317 public function display_page_builder_notice() {
5318 + // Only relevant on the plugin's own settings page — avoid repeating
5319 + // this notice across every admin screen.
5320 + if (!isset($_GET['page']) || wp_unslash($_GET['page']) !== self::$page_slug) {
5321 + return;
5322 + }
5323 +
4699 5324 $configured = Metasync::get_option('general')['default_page_builder'] ?? '';
4700 5325
4701 5326 // Setting already saved — nothing to warn about
4702 5327 if (!empty($configured)) {
@@ -4709,9 +5334,9 @@
4709 5334 return;
4710 5335 }
4711 5336
4712 5337 // Handle dismiss action
4713 - if (isset($_GET['metasync_dismiss_builder_notice']) && wp_verify_nonce($_GET['_wpnonce'] ?? '', 'metasync_dismiss_builder')) {
5338 + if (isset($_GET['metasync_dismiss_builder_notice'], $_GET['_wpnonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'metasync_dismiss_builder')) {
4714 5339 update_user_meta(get_current_user_id(), 'metasync_builder_notice_dismissed', '1');
4715 5340 return;
4716 5341 }
4717 5342
@@ -4806,9 +5431,10 @@
4806 5431 * Saves the CPU load threshold and returns statistics
4807 5432 */
4808 5433 public function ajax_save_performance_settings() {
4809 5434 # Check nonce for security and return early if invalid
4810 - if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce($_POST['meta_sync_nonce'], 'meta_sync_general_setting_nonce')) {
5435 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5436 + if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce(wp_unslash($_POST['meta_sync_nonce']), 'meta_sync_general_setting_nonce')) {
4811 5437 wp_send_json_error(array('message' => 'Invalid nonce'));
4812 5438 return;
4813 5439 }
4814 5440
@@ -4876,9 +5502,9 @@
4876 5502 if (!wp_next_scheduled('metasync_cleanup_transients')) {
4877 5503 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_cleanup_transients');
4878 5504
4879 5505 if (!$scheduled) {
4880 - error_log('MetaSync: Failed to schedule transient cleanup cron job');
5506 + error_log('MetaSync: Failed to schedule transient cleanup cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
4881 5507 }
4882 5508 }
4883 5509 }
4884 5510
@@ -4889,9 +5515,9 @@
4889 5515 {
4890 5516 $timestamp = wp_next_scheduled('metasync_cleanup_transients');
4891 5517 if ($timestamp) {
4892 5518 wp_unschedule_event($timestamp, 'metasync_cleanup_transients');
4893 - error_log('MetaSync: Transient cleanup cron job unscheduled');
5519 + error_log('MetaSync: Transient cleanup cron job unscheduled'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- unreachable in production
4894 5520 }
4895 5521 }
4896 5522
4897 5523 /**
@@ -4913,12 +5539,10 @@
4913 5539 {
4914 5540 if (!wp_next_scheduled('metasync_hidden_post_check')) {
4915 5541 $scheduled = wp_schedule_event(time(), 'metasync_weekly', 'metasync_hidden_post_check');
4916 5542
4917 - if ($scheduled) {
4918 - error_log('MetaSync: Hidden post manager cron job scheduled successfully (runs every 7 days)');
4919 - } else {
4920 - error_log('MetaSync: Failed to schedule hidden post manager cron job');
5543 + if (!$scheduled) {
5544 + error_log('MetaSync: Failed to schedule hidden post manager cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
4921 5545 }
4922 5546 }
4923 5547 }
4924 5548
@@ -4929,12 +5553,10 @@
4929 5553 public function maybe_schedule_otto_recheck_404_cron()
4930 5554 {
4931 5555 if (!wp_next_scheduled('metasync_otto_recheck_404_exclusions')) {
4932 5556 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_otto_recheck_404_exclusions');
4933 - if ($scheduled) {
4934 - error_log('MetaSync: OTTO 404 recheck cron job scheduled successfully (runs daily)');
4935 - } else {
4936 - error_log('MetaSync: Failed to schedule OTTO 404 recheck cron job');
5557 + if (!$scheduled) {
5558 + error_log('MetaSync: Failed to schedule OTTO 404 recheck cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
4937 5559 }
4938 5560 }
4939 5561 }
4940 5562
@@ -5007,13 +5629,13 @@
5007 5629
5008 5630 try {
5009 5631 // 1. Post revisions
5010 5632 if (!empty($settings['clean_post_revisions'])) {
5011 - $stats['post_revisions'] = (int) $wpdb->query(
5633 + $stats['post_revisions'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5012 5634 "DELETE FROM {$wpdb->posts} WHERE post_type = 'revision'"
5013 5635 );
5014 5636 // Remove postmeta left behind by deleted revisions
5015 - $wpdb->query(
5637 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5016 5638 "DELETE pm FROM {$wpdb->postmeta} pm
5017 5639 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5018 5640 WHERE p.ID IS NULL"
5019 5641 );
@@ -5021,20 +5643,19 @@
5021 5643
5022 5644 // 2. Trashed posts + their postmeta
5023 5645 if (!empty($settings['clean_trashed_posts'])) {
5024 5646 // Collect IDs first to cleanly remove postmeta
5025 - $trashed_ids = $wpdb->get_col(
5647 + $trashed_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5026 5648 "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'trash'"
5027 5649 );
5028 5650 if (!empty($trashed_ids)) {
5029 - $placeholders = implode(',', array_fill(0, count($trashed_ids), '%d'));
5030 - $wpdb->query(
5651 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5031 5652 $wpdb->prepare(
5032 - "DELETE FROM {$wpdb->postmeta} WHERE post_id IN ($placeholders)",
5653 + 'DELETE FROM ' . $wpdb->postmeta . ' WHERE post_id IN (' . implode(',', array_fill(0, count($trashed_ids), '%d')) . ')',
5033 5654 $trashed_ids
5034 5655 )
5035 5656 );
5036 - $stats['trashed_posts'] = (int) $wpdb->query(
5657 + $stats['trashed_posts'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5037 5658 "DELETE FROM {$wpdb->posts} WHERE post_status = 'trash'"
5038 5659 );
5039 5660 } else {
5040 5661 $stats['trashed_posts'] = 0;
@@ -5042,9 +5663,9 @@
5042 5663 }
5043 5664
5044 5665 // 3. Trashed comments
5045 5666 if (!empty($settings['clean_trashed_comments'])) {
5046 - $stats['trashed_comments'] = (int) $wpdb->query(
5667 + $stats['trashed_comments'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5047 5668 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'trash'"
5048 5669 );
5049 5670 }
5050 5671
@@ -5049,9 +5670,9 @@
5049 5670 }
5050 5671
5051 5672 // 4. Spam comments
5052 5673 if (!empty($settings['clean_spam_comments'])) {
5053 - $stats['spam_comments'] = (int) $wpdb->query(
5674 + $stats['spam_comments'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5054 5675 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'spam'"
5055 5676 );
5056 5677 }
5057 5678
@@ -5057,15 +5678,15 @@
5057 5678
5058 5679 // 5. Expired transients — direct SQL, no cache flush
5059 5680 if (!empty($settings['clean_expired_transients'])) {
5060 5681 // Delete timeout rows that have already expired
5061 - $wpdb->query(
5682 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5062 5683 "DELETE FROM {$wpdb->options}
5063 5684 WHERE option_name LIKE '\_transient\_timeout\_%'
5064 5685 AND option_value + 0 < UNIX_TIMESTAMP()"
5065 5686 );
5066 5687 // Delete value rows whose timeout row no longer exists
5067 - $stats['expired_transients'] = (int) $wpdb->query(
5688 + $stats['expired_transients'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5068 5689 "DELETE o FROM {$wpdb->options} o
5069 5690 LEFT JOIN {$wpdb->options} t
5070 5691 ON t.option_name = CONCAT('_transient_timeout_', SUBSTRING(o.option_name, 12))
5071 5692 WHERE o.option_name LIKE '\_transient\_%'
@@ -5075,9 +5696,9 @@
5075 5696 }
5076 5697
5077 5698 // 6. Orphaned postmeta (post_id references a post that no longer exists)
5078 5699 if (!empty($settings['clean_orphaned_postmeta'])) {
5079 - $stats['orphaned_postmeta'] = (int) $wpdb->query(
5700 + $stats['orphaned_postmeta'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5080 5701 "DELETE pm FROM {$wpdb->postmeta} pm
5081 5702 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5082 5703 WHERE p.ID IS NULL"
5083 5704 );
@@ -5089,12 +5710,10 @@
5089 5710 $settings['last_run_at'] = time();
5090 5711 $settings['last_run_stats'] = $stats;
5091 5712 update_option('metasync_db_cleanup_settings', $settings);
5092 5713
5093 - error_log('MetaSync: DB cleanup completed — ' . json_encode($stats));
5094 -
5095 5714 } catch (Exception $e) {
5096 - error_log('MetaSync: DB cleanup failed — ' . $e->getMessage());
5715 + error_log('MetaSync: DB cleanup failed — ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5097 5716 }
5098 5717 }
5099 5718
5100 5719 /**
@@ -5311,10 +5930,11 @@
5311 5930 /**
5312 5931 * AJAX: Save DB cleanup settings and reschedule cron accordingly.
5313 5932 */
5314 5933 public function ajax_save_db_cleanup_settings() {
5934 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5315 5935 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5316 - !wp_verify_nonce($_POST['db_cleanup_settings_nonce'], 'metasync_db_cleanup_settings_nonce')) {
5936 + !wp_verify_nonce(wp_unslash($_POST['db_cleanup_settings_nonce']), 'metasync_db_cleanup_settings_nonce')) {
5317 5937 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
5318 5938 return;
5319 5939 }
5320 5940
@@ -5371,10 +5991,11 @@
5371 5991 * AJAX: Manually trigger the DB cleanup and return stats for the UI.
5372 5992 * Persists current form state first so unsaved checkbox changes are respected.
5373 5993 */
5374 5994 public function ajax_run_db_cleanup() {
5995 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5375 5996 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5376 - !wp_verify_nonce($_POST['db_cleanup_settings_nonce'], 'metasync_db_cleanup_settings_nonce')) {
5997 + !wp_verify_nonce(wp_unslash($_POST['db_cleanup_settings_nonce']), 'metasync_db_cleanup_settings_nonce')) {
5377 5998 wp_send_json_error(array('message' => 'Invalid security token.'));
5378 5999 return;
5379 6000 }
5380 6001
@@ -5450,35 +6071,8 @@
5450 6071 ));
5451 6072 }
5452 6073
5453 6074 /**
5454 - * Control plugin auto-updates based on user setting
5455 - *
5456 - * @param bool $update Whether to update
5457 - * @param object $item Update offer
5458 - * @return bool Whether to allow auto-update
5459 - */
5460 - public function control_plugin_auto_updates($update, $item)
5461 - {
5462 - # Check if the item object has the slug property
5463 - if (!isset($item->slug)) {
5464 - return $update;
5465 - }
5466 -
5467 - // Check if this is our plugin
5468 - if ($item->slug === 'metasync') {
5469 - $general_settings = Metasync::get_option('general') ?? [];
5470 - $enable_auto_updates = $general_settings['enable_auto_updates'] ?? false;
5471 -
5472 - // Return the user's preference (true = allow auto-updates, false = prevent)
5473 - return $enable_auto_updates === 'true' || $enable_auto_updates === true;
5474 - }
5475 -
5476 - // For other plugins, don't interfere with their auto-update settings
5477 - return $update;
5478 - }
5479 -
5480 - /**
5481 6075 * AJAX handler to add excluded URL for OTTO
5482 6076 */
5483 6077 public function ajax_otto_add_excluded_url()
5484 6078 {
@@ -5624,8 +6218,9 @@
5624 6218 // If page has raw HTML or custom CSS from conversion, show badge
5625 6219 if ($has_raw_html || !empty($has_custom_css)) {
5626 6220 $label = $this->get_html_source_label();
5627 6221 $tooltip = sprintf(
6222 + /* translators: %s: HTML source label. */
5628 6223 __('This page was created using %s HTML-to-Builder converter', 'metasync'),
5629 6224 $label
5630 6225 );
5631 6226
@@ -5664,8 +6259,16 @@
5664 6259 if (!$post || !in_array($post->post_type, array('post', 'page'))) {
5665 6260 return;
5666 6261 }
5667 6262
6263 + // Don't show the "HTML-to-Builder converter" banner on LPS-synced / custom-HTML
6264 + // pages: those are raw-HTML store-and-serve pages, NOT actually converted to a
6265 + // page builder, so the banner mislabels them. It still shows for pages genuinely
6266 + // produced by the converter.
6267 + if (function_exists('metasync_is_custom_or_lps_page') && metasync_is_custom_or_lps_page($post->ID)) {
6268 + return;
6269 + }
6270 +
5668 6271 $has_raw_html = get_post_meta($post->ID, '_metasync_raw_html_enabled', true);
5669 6272 $has_custom_css = get_post_meta($post->ID, '_metasync_custom_css', true);
5670 6273
5671 6274 if ($has_raw_html || !empty($has_custom_css)) {
@@ -5670,8 +6273,9 @@
5670 6273
5671 6274 if ($has_raw_html || !empty($has_custom_css)) {
5672 6275 $label = $this->get_html_source_label();
5673 6276 $message = sprintf(
6277 + /* translators: %s: HTML source label. */
5674 6278 __('This page was created using %s HTML-to-Builder converter. The design is preserved with custom CSS and inline styles.', 'metasync'),
5675 6279 '<strong>' . esc_html($label) . '</strong>'
5676 6280 );
5677 6281
@@ -5685,9 +6289,9 @@
5685 6289 <p class="metasync-editor-message">%s</p>
5686 6290 </div>
5687 6291 </div>',
5688 6292 esc_html($label),
5689 - $message
6293 + wp_kses($message, array('strong' => array()))
5690 6294 );
5691 6295 }
5692 6296 }
5693 6297
@@ -5710,9 +6314,9 @@
5710 6314 ?>
5711 6315 <fieldset class="inline-edit-col-left metasync-quick-edit-source">
5712 6316 <div class="inline-edit-col">
5713 6317 <label>
5714 - <span class="title"><?php _e('Source', 'metasync'); ?></span>
6318 + <span class="title"><?php esc_html_e('Source', 'metasync'); ?></span>
5715 6319 <span class="metasync-quick-edit-badge-container"></span>
5716 6320 </label>
5717 6321 </div>
5718 6322 </fieldset>
@@ -5724,8 +6328,9 @@
5724 6328 */
5725 6329 public function add_html_pages_dashboard_widget()
5726 6330 {
5727 6331 $label = $this->get_html_source_label();
6332 + /* translators: %s: HTML source label. */
5728 6333 $widget_title = sprintf(__('%s Pages', 'metasync'), $label);
5729 6334
5730 6335 wp_add_dashboard_widget(
5731 6336 'metasync_html_pages_widget',
@@ -5781,8 +6386,14 @@
5781 6386 * @return void Sends JSON response and exits
5782 6387 */
5783 6388 public function ajax_send_giapi()
5784 6389 {
6390 + check_ajax_referer('metasync_nonce', 'nonce');
6391 +
6392 + if (!Metasync::current_user_has_plugin_access()) {
6393 + wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
6394 + }
6395 +
5785 6396 $post_data = metasync_sanitize_input_array($_POST);
5786 6397 if (!isset($post_data['metasync_giapi_url'])) {
5787 6398 return;
5788 6399 }
@@ -5848,8 +6459,14 @@
5848 6459 * @return void Sends JSON response and exits
5849 6460 */
5850 6461 public function ajax_send_bing_indexnow()
5851 6462 {
6463 + check_ajax_referer('metasync_nonce', 'nonce');
6464 +
6465 + if (!Metasync::current_user_has_plugin_access()) {
6466 + wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
6467 + }
6468 +
5852 6469 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
5853 6470 $bing_instant_index = new Metasync_Bing_Instant_Index();
5854 6471 $bing_instant_index->send();
5855 6472 }
@@ -5866,8 +6483,18 @@
5866 6483 if (!isset($_POST['submit'])) {
5867 6484 return;
5868 6485 }
5869 6486
6487 + // This handler runs on admin_init, which also fires inside
6488 + // admin-ajax.php before its login gate, so a bare isset() check let
6489 + // any request (including logged-out ones) rewrite the auto-submit
6490 + // post types. Require the nonce and plugin access before writing.
6491 + if (!isset($_POST['metasync_instant_indexing_nonce'])
6492 + || !wp_verify_nonce(sanitize_key(wp_unslash($_POST['metasync_instant_indexing_nonce'])), 'metasync_instant_indexing_settings')
6493 + || !Metasync::current_user_has_plugin_access()) {
6494 + return;
6495 + }
6496 +
5870 6497 // Save post types for Google Instant Indexing auto-submit
5871 6498 if (isset($_POST['metasync_post_types'])) {
5872 6499 $post_data = metasync_sanitize_input_array($_POST);
5873 6500 $post_types = is_array($post_data['metasync_post_types']) ? array_map('sanitize_title', $post_data['metasync_post_types']) : [];
@@ -5951,12 +6578,24 @@
5951 6578 if (!empty($seo_controls['enable_googleinstantindex']) && $seo_controls['enable_googleinstantindex'] === 'true') {
5952 6579 $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
5953 6580 $post_types = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
5954 6581
5955 - if (in_array($post->post_type, $post_types) && function_exists('google_index_direct')) {
5956 - $service_info = google_index_direct()->get_service_account_info();
5957 - if (!isset($service_info['error'])) {
5958 - google_index_direct()->index_post($post_id, $post->post_type, 'update');
6582 + if (in_array($post->post_type, $post_types)) {
6583 + // save_post is the single owner of auto-submit and also fires
6584 + // on REST-created posts, where nothing else may have loaded
6585 + // the Google Index helpers yet — load them on demand instead
6586 + // of silently skipping.
6587 + if (!function_exists('google_index_direct')) {
6588 + $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
6589 + if (file_exists($google_index_path)) {
6590 + require_once $google_index_path;
6591 + }
6592 + }
6593 + if (function_exists('google_index_direct')) {
6594 + $service_info = google_index_direct()->get_service_account_info();
6595 + if (!isset($service_info['error'])) {
6596 + google_index_direct()->index_post($post_id, $post->post_type, 'update');
6597 + }
5959 6598 }
5960 6599 }
5961 6600 }
5962 6601