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 +1223 -548 2.6.5 → 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
@@ -253,10 +256,15 @@
253 256
254 257 // Sync plugin file headers whenever metasync_options is updated (covers AJAX save, Settings API, import)
255 258 add_action('update_option_metasync_options', array($this, 'on_options_updated_sync_file_headers'), 10, 2);
256 259
260 + // Invalidate the admin bar status cache when settings change or the API key is rotated.
261 + add_action('update_option_metasync_options', array('Metasync_Admin_Navigation', 'invalidate_admin_bar_status_cache'), 10, 0);
262 + add_action('metasync_api_key_changed', array('Metasync_Admin_Navigation', 'invalidate_admin_bar_status_cache'), 10, 0);
263 +
257 264 add_action('admin_init', array($this, 'initialize_cookie'));
258 265 add_action('admin_init', array($this, 'maybe_redirect_to_wizard'));
266 + add_action('admin_init', array($this, 'redirect_legacy_instant_indexing_page'));
259 267
260 268 // Add admin_post hooks for form submissions (WordPress standard way - no output buffering needed)
261 269 add_action('admin_post_metasync_clear_all_cache_plugins', array($this, 'handle_clear_all_cache_plugins'));
262 270 add_action('admin_post_metasync_clear_otto_cache_all', array($this, 'handle_clear_otto_cache_all'));
@@ -274,10 +282,16 @@
274 282
275 283 // Add AJAX for saving execution settings
276 284 add_action( 'wp_ajax_metasync_save_execution_settings', array($this, 'ajax_save_execution_settings') );
277 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 +
278 289 // Add AJAX for saving hosting cache settings
279 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'));
280 294 add_action( 'wp_ajax_metasync_save_object_cache_settings', array($this, 'ajax_save_object_cache_settings') );
281 295
282 296 // Add AJAX for saving edge cache / CDN settings
283 297 add_action( 'wp_ajax_metasync_save_edge_cache_settings', array('Metasync_Edge_Cache_Settings', 'ajax_save') );
@@ -302,8 +316,9 @@
302 316 add_action('wp_ajax_metasync_get_default_robots', array($this, 'ajax_get_default_robots'));
303 317 add_action('wp_ajax_metasync_preview_robots_backup', array($this, 'ajax_preview_robots_backup'));
304 318 add_action('wp_ajax_metasync_delete_robots_backup', array($this, 'ajax_delete_robots_backup'));
305 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'));
306 321
307 322 // Add AJAX handlers for host blocking test
308 323 add_action('wp_ajax_metasync_test_host_blocking_get', array($this, 'ajax_test_host_blocking_get'));
309 324 add_action('wp_ajax_metasync_test_host_blocking_post', array($this, 'ajax_test_host_blocking_post'));
@@ -355,11 +370,20 @@
355 370 add_action('wp_ajax_metasync_start_batch_optimize', array($this, 'ajax_start_batch_optimize'));
356 371 add_action('wp_ajax_metasync_cancel_batch_optimize', array($this, 'ajax_cancel_batch_optimize'));
357 372 add_action('wp_ajax_metasync_batch_progress', array($this, 'ajax_batch_progress'));
358 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'));
359 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'));
360 377 add_action('metasync_media_batch_optimize_cron', array($this, 'handle_media_batch_cron'));
361 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 +
362 386 # Add AJAX handlers for Google Instant Indexing
363 387 add_action('wp_ajax_metasync_send_giapi', array($this, 'ajax_send_giapi'));
364 388
365 389 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
@@ -452,9 +476,11 @@
452 476
453 477 $data= Metasync::get_option('general');
454 478
455 479 # Get white label menu slug
456 - $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;
457 483
458 484 ?>
459 485 <style>
460 486 #toplevel_page_<?php echo esc_attr(str_replace(' ', '-',$menu_slug)); ?> .wp-menu-image.dashicons-before img {
@@ -471,16 +497,129 @@
471 497 <?php
472 498 }
473 499
474 500 public function metasync_fouc_prevention_style() {
475 - 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
476 502 return;
477 503 }
478 504 ?>
479 - <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>
480 521 <?php
481 522 }
482 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 +
483 622 #---------fixes issue : #95 ----------
484 623 #This function is to redirect in case client changes slug on fresh install
485 624 #It is called by the add_option hook
486 625
@@ -493,15 +632,22 @@
493 632
494 633 #check if we have the cookie set and check if the slug has changed
495 634 if (isset($_COOKIE['metasync_previous_slug']) && $_COOKIE['metasync_previous_slug'] !== $current_slug) {
496 635 # the slug changed so we need to update the cookie
497 - setcookie('metasync_previous_slug', $current_slug, time() + 3600, COOKIEPATH, COOKIE_DOMAIN);
636 + setcookie('metasync_previous_slug', $current_slug, [
637 + 'expires' => time() + 3600,
638 + 'path' => COOKIEPATH,
639 + 'domain' => COOKIE_DOMAIN,
640 + 'secure' => is_ssl(),
641 + 'httponly' => true,
642 + 'samesite' => 'Lax',
643 + ]);
498 644 $_COOKIE['metasync_previous_slug'] = $current_slug;
499 645
500 646 #Redirect url to the new slug
501 647 $redirect_url = admin_url('admin.php?page=' . $current_slug);
502 648
503 - wp_redirect($redirect_url);
649 + wp_safe_redirect($redirect_url);
504 650 exit;
505 651 }
506 652 }
507 653
@@ -534,9 +680,16 @@
534 680 $data = Metasync::get_option('general');
535 681 // Retrieve the current slug
536 682 $initial_slug = isset($data['white_label_plugin_menu_slug']) ? $data['white_label_plugin_menu_slug'] : self::$page_slug;
537 683 // Set the cookie
538 - setcookie('metasync_previous_slug', $initial_slug, time() + 3600, COOKIEPATH, COOKIE_DOMAIN);
684 + setcookie('metasync_previous_slug', $initial_slug, [
685 + 'expires' => time() + 3600,
686 + 'path' => COOKIEPATH,
687 + 'domain' => COOKIE_DOMAIN,
688 + 'secure' => is_ssl(),
689 + 'httponly' => true,
690 + 'samesite' => 'Lax',
691 + ]);
539 692 }
540 693 }
541 694 }
542 695
@@ -546,13 +699,13 @@
546 699 * @since 1.0.0
547 700 */
548 701 public function maybe_redirect_to_wizard() {
549 702 // Only redirect if wizard should be shown
550 - 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
551 704 delete_option('metasync_show_wizard');
552 705
553 706 // Don't redirect during AJAX, cron, or bulk activation
554 - 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
555 708 return;
556 709 }
557 710
558 711 // Only redirect if user has access to the plugin
@@ -564,11 +717,56 @@
564 717 exit;
565 718 }
566 719 }
567 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 +
568 765 public function metasync_display_error_log() {
569 766 Metasync_Debug_Manager::instance()->metasync_display_error_log($this);
570 767 }
768 +
571 769 public function metasync_update_wp_config() {
572 770 Metasync_Debug_Manager::instance()->metasync_update_wp_config();
573 771 }
574 772
@@ -600,13 +798,20 @@
600 798 $old_slug = $old_value['general']['white_label_plugin_menu_slug'] ?? self::$page_slug;
601 799
602 800 if ($new_slug !== $old_slug && $old_slug !=='' ){
603 801 // Set a new cookie
604 - setcookie('metasync_previous_slug', $new_slug, time() + 3600, COOKIEPATH, COOKIE_DOMAIN);
802 + setcookie('metasync_previous_slug', $new_slug, [
803 + 'expires' => time() + 3600,
804 + 'path' => COOKIEPATH,
805 + 'domain' => COOKIE_DOMAIN,
806 + 'secure' => is_ssl(),
807 + 'httponly' => true,
808 + 'samesite' => 'Lax',
809 + ]);
605 810 $_COOKIE['metasync_previous_slug'] = $new_slug;
606 811 // Redirect to the new slug
607 812 $redirect_url = admin_url('admin.php?page=' . $old_slug);
608 - wp_redirect($redirect_url);
813 + wp_safe_redirect($redirect_url);
609 814 exit;
610 815 }else{
611 816 self::$page_slug = Metasync::get_option('general')['white_label_plugin_menu_slug']=="" ? "searchatlas":Metasync::get_option('general')['white_label_plugin_menu_slug'];
612 817 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
@@ -612,9 +817,9 @@
612 817 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
613 818
614 819 #add redirection for when the old slug is not defined
615 820 #this fixes the redirect issue #
616 - wp_redirect($redirect_url);
821 + wp_safe_redirect($redirect_url);
617 822 exit;
618 823 }
619 824 }
620 825 public function metasync_view_detials_url( $plugin_meta, $plugin_file, $plugin_data ) {
@@ -625,9 +830,10 @@
625 830 if (strpos($meta, 'open-plugin-details-modal') !== false) {
626 831 $meta = sprintf(
627 832 '<a href="%s" class="thickbox open-plugin-details-modal" aria-label="%s" data-title="%s">%s</a>',
628 833 add_query_arg('TB_iframe', 'true', $plugin_uri),
629 - 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'])),
630 836 esc_attr($plugin_data['Name']),
631 837 esc_html__('View details', 'metasync')
632 838 );
633 839 break; // Exit loop after replacing the link
@@ -675,53 +881,8 @@
675 881 return $all_plugins;
676 882 }
677 883
678 884 /**
679 - * Inject the whitelabel icon into the update_plugins transient so that
680 - * /wp-admin/update-core.php (Dashboard → Updates) shows the WL icon
681 - * instead of the SearchAtlas icon returned by the update API.
682 - *
683 - * @param object $transient The site transient object.
684 - * @return object
685 - */
686 - public function inject_whitelabel_icon_into_update_transient($transient) {
687 - if (empty($transient) || !is_object($transient)) {
688 - return $transient;
689 - }
690 -
691 - $general = Metasync::get_option('general');
692 - if (!is_array($general)) {
693 - return $transient;
694 - }
695 -
696 - $icon_url = $general['white_label_plugin_menu_icon'] ?? '';
697 - if (empty($icon_url)) {
698 - return $transient;
699 - }
700 -
701 - $this_plugin = plugin_basename(dirname(__DIR__) . '/metasync.php');
702 -
703 - // Inject into pending updates list
704 - if (!empty($transient->response) && isset($transient->response[$this_plugin])) {
705 - $transient->response[$this_plugin]->icons = [
706 - '1x' => $icon_url,
707 - '2x' => $icon_url,
708 - ];
709 - }
710 -
711 - // Also inject into the "no update needed" list so the icon appears
712 - // on the updates screen even when the plugin is up-to-date
713 - if (!empty($transient->no_update) && isset($transient->no_update[$this_plugin])) {
714 - $transient->no_update[$this_plugin]->icons = [
715 - '1x' => $icon_url,
716 - '2x' => $icon_url,
717 - ];
718 - }
719 -
720 - return $transient;
721 - }
722 -
723 - /**
724 885 * Register the stylesheets for the admin area.
725 886 *
726 887 * @since 1.0.0
727 888 */
@@ -726,37 +887,40 @@
726 887 * @since 1.0.0
727 888 */
728 889 public function enqueue_styles()
729 890 {
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
730 892
731 - wp_enqueue_style(
732 - $this->plugin_name,
733 - plugin_dir_url(__FILE__) . 'css/metasync-admin.css',
734 - array(),
735 - $this->version,
736 - 'all'
737 - );
893 + if (strpos($current_page, self::$page_slug) === 0) {
894 + wp_enqueue_style(
895 + $this->plugin_name,
896 + plugin_dir_url(__FILE__) . 'css/metasync-admin.css',
897 + array(),
898 + $this->version,
899 + 'all'
900 + );
738 901
739 - // Enqueue dashboard-style CSS for admin pages
740 - wp_enqueue_style(
741 - $this->plugin_name . '-dashboard',
742 - plugin_dir_url(__FILE__) . 'css/metasync-dashboard.css',
743 - array($this->plugin_name),
744 - $this->version,
745 - 'all'
746 - );
902 + // Enqueue dashboard-style CSS for admin pages
903 + wp_enqueue_style(
904 + $this->plugin_name . '-dashboard',
905 + plugin_dir_url(__FILE__) . 'css/metasync-dashboard.css',
906 + array($this->plugin_name),
907 + $this->version,
908 + 'all'
909 + );
747 910
748 - // Enqueue 3-column layout CSS
749 - wp_enqueue_style(
750 - $this->plugin_name . '-layout',
751 - plugin_dir_url(__FILE__) . 'css/metasync-layout.css',
752 - array($this->plugin_name . '-dashboard'),
753 - $this->version,
754 - 'all'
755 - );
911 + // Enqueue 3-column layout CSS
912 + wp_enqueue_style(
913 + $this->plugin_name . '-layout',
914 + plugin_dir_url(__FILE__) . 'css/metasync-layout.css',
915 + array($this->plugin_name . '-dashboard'),
916 + $this->version,
917 + 'all'
918 + );
919 + }
756 920
757 921 // Enqueue wizard CSS if on wizard page
758 - 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
759 923 wp_enqueue_style(
760 924 $this->plugin_name . '-setup-wizard',
761 925 plugin_dir_url(__FILE__) . 'css/metasync-setup-wizard.css',
762 926 array($this->plugin_name . '-dashboard'),
@@ -765,9 +929,9 @@
765 929 );
766 930 }
767 931
768 932 // Enqueue SEO Health CSS if on the SEO Health page
769 - 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
770 934 wp_enqueue_style(
771 935 $this->plugin_name . '-seo-health',
772 936 plugin_dir_url(__FILE__) . 'css/metasync-seo-health.css',
773 937 array($this->plugin_name . '-dashboard'),
@@ -783,41 +947,82 @@
783 947 * @since 1.0.0
784 948 */
785 949 public function enqueue_scripts()
786 950 {
951 + // --- Phase 5 (#887): Extracted inline JS files ---
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
953 + $plugin_root_url = plugin_dir_url(dirname(__FILE__));
954 + $is_metasync_page = ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0);
787 955
788 - wp_enqueue_media();
956 + if ($is_metasync_page) {
957 + wp_enqueue_media();
789 958
790 - wp_enqueue_script(
791 - $this->plugin_name,
792 - plugin_dir_url(__FILE__) . 'js/metasync-admin.js',
793 - array('jquery'),
794 - $this->version,
795 - false
796 - );
959 + wp_enqueue_script(
960 + $this->plugin_name,
961 + plugin_dir_url(__FILE__) . 'js/metasync-admin.js',
962 + array('jquery'),
963 + $this->version,
964 + false
965 + );
797 966
798 - // Enqueue dashboard-style JavaScript for enhanced interactions
799 - wp_enqueue_script(
800 - $this->plugin_name . '-dashboard',
801 - plugin_dir_url(__FILE__) . 'js/metasync-dashboard.js',
802 - array('jquery', $this->plugin_name),
803 - $this->version,
804 - true
805 - );
967 + // Enqueue dashboard-style JavaScript for enhanced interactions
968 + wp_enqueue_script(
969 + $this->plugin_name . '-dashboard',
970 + plugin_dir_url(__FILE__) . 'js/metasync-dashboard.js',
971 + array('jquery', $this->plugin_name),
972 + $this->version,
973 + true
974 + );
806 975
807 - # Enqueue theme switcher
808 - wp_enqueue_script(
809 - $this->plugin_name . '-theme-switcher',
810 - plugin_dir_url(__FILE__) . 'js/metasync-theme-switcher.js',
811 - array('jquery'),
812 - $this->version,
813 - true
814 - );
815 -
816 - // --- Phase 5 (#887): Extracted inline JS files ---
817 - $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
818 - $plugin_root_url = plugin_dir_url(dirname(__FILE__));
976 + # Enqueue theme switcher
977 + wp_enqueue_script(
978 + $this->plugin_name . '-theme-switcher',
979 + plugin_dir_url(__FILE__) . 'js/metasync-theme-switcher.js',
980 + array('jquery'),
981 + $this->version,
982 + true
983 + );
819 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 + );
995 + }
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 +
820 1025 // Dashboard iframe height (only on dashboard page)
821 1026 if ($current_page === self::$page_slug . '-dashboard' || $current_page === self::$page_slug) {
822 1027 wp_enqueue_script(
823 1028 $this->plugin_name . '-iframe',
@@ -828,9 +1033,9 @@
828 1033 );
829 1034 }
830 1035
831 1036 // Settings page scripts (save btn, clear settings, bing key, access roles)
832 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1037 + if ($current_page === self::$page_slug) {
833 1038 wp_enqueue_script(
834 1039 $this->plugin_name . '-settings',
835 1040 plugin_dir_url(__FILE__) . 'js/metasync-settings.js',
836 1041 array('jquery'),
@@ -866,12 +1071,12 @@
866 1071 true
867 1072 );
868 1073 // Pass the active tab from server (handles POST redirect)
869 1074 $sitemap_active_tab = 'general';
870 - if (isset($_GET['tab']) && in_array($_GET['tab'], ['general', 'news', 'video'], true)) {
871 - $sitemap_active_tab = sanitize_text_field(wp_unslash($_GET['tab']));
872 - } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) {
873 - $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
874 1079 }
875 1080 wp_localize_script($this->plugin_name . '-sitemap-tabs', 'metasyncSitemapTabs', [
876 1081 'activeTab' => $sitemap_active_tab,
877 1082 ]);
@@ -877,9 +1082,9 @@
877 1082 ]);
878 1083 }
879 1084
880 1085 // Error logs — copy to clipboard
881 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1086 + if ($current_page === self::$page_slug) {
882 1087 wp_enqueue_script(
883 1088 $this->plugin_name . '-error-logs',
884 1089 $plugin_root_url . 'site-error-logs/js/metasync-error-logs.js',
885 1090 array(),
@@ -888,9 +1093,9 @@
888 1093 );
889 1094 }
890 1095
891 1096 // Access control UI
892 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1097 + if ($current_page === self::$page_slug) {
893 1098 wp_enqueue_script(
894 1099 $this->plugin_name . '-access-control',
895 1100 $plugin_root_url . 'includes/js/metasync-access-control.js',
896 1101 array(),
@@ -927,9 +1132,9 @@
927 1132 // --- Phase 5 Part B: Extracted inline JS with wp_localize_script ---
928 1133
929 1134 // Navigation portal menus (top bar + settings inner page)
930 1135 $whitelabel_data = Metasync::get_option('whitelabel');
931 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1136 + if ($is_metasync_page) {
932 1137 wp_enqueue_script(
933 1138 $this->plugin_name . '-navigation',
934 1139 plugin_dir_url(__FILE__) . 'js/metasync-navigation.js',
935 1140 array(),
@@ -943,9 +1148,9 @@
943 1148 ));
944 1149 }
945 1150
946 1151 // Debug mode timer
947 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1152 + if ($current_page === self::$page_slug) {
948 1153 $debug_manager = Metasync_Debug_Mode_Manager::get_instance();
949 1154 $debug_status = $debug_manager->get_status();
950 1155 wp_enqueue_script(
951 1156 $this->plugin_name . '-debug-mode',
@@ -963,9 +1168,9 @@
963 1168 ));
964 1169 }
965 1170
966 1171 // MetasyncConfig + admin bar sync
967 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1172 + if ($is_metasync_page) {
968 1173 wp_enqueue_script(
969 1174 $this->plugin_name . '-config',
970 1175 plugin_dir_url(__FILE__) . 'js/metasync-config.js',
971 1176 array('jquery'),
@@ -978,9 +1183,9 @@
978 1183 ));
979 1184 }
980 1185
981 1186 // Whitelabel password (forgot password handler)
982 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1187 + if ($current_page === self::$page_slug) {
983 1188 wp_enqueue_script(
984 1189 $this->plugin_name . '-whitelabel',
985 1190 plugin_dir_url(__FILE__) . 'js/metasync-whitelabel.js',
986 1191 array('jquery'),
@@ -992,9 +1197,9 @@
992 1197 ));
993 1198 }
994 1199
995 1200 // Whitelabel connect (validation modal + lock section + export)
996 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1201 + if ($current_page === self::$page_slug) {
997 1202 wp_enqueue_script(
998 1203 $this->plugin_name . '-connect',
999 1204 plugin_dir_url(__FILE__) . 'js/metasync-connect.js',
1000 1205 array('jquery'),
@@ -1002,9 +1207,8 @@
1002 1207 true
1003 1208 );
1004 1209 wp_localize_script($this->plugin_name . '-connect', 'metasyncConnectData', array(
1005 1210 'optionKey' => self::option_key,
1006 - 'storedPassword' => isset($whitelabel_data['settings_password']) ? $whitelabel_data['settings_password'] : '',
1007 1211 'adminPostUrl' => admin_url('admin-post.php'),
1008 1212 'exportNonce' => wp_create_nonce('metasync_export_whitelabel'),
1009 1213 'ajaxUrl' => admin_url('admin-ajax.php'),
1010 1214 'logoutNonceField' => wp_nonce_field('whitelabel_logout_nonce', 'whitelabel_logout_nonce', true, false),
@@ -1011,9 +1215,9 @@
1011 1215 ));
1012 1216 }
1013 1217
1014 1218 // Host blocking test (settings + dashboard)
1015 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1219 + if ($current_page === self::$page_slug || $current_page === self::$page_slug . '-dashboard') {
1016 1220 wp_enqueue_script(
1017 1221 $this->plugin_name . '-host-blocking',
1018 1222 plugin_dir_url(__FILE__) . 'js/metasync-host-blocking.js',
1019 1223 array('jquery'),
@@ -1021,13 +1225,14 @@
1021 1225 true
1022 1226 );
1023 1227 wp_localize_script($this->plugin_name . '-host-blocking', 'metasyncHostBlockingData', array(
1024 1228 'ajaxUrl' => admin_url('admin-ajax.php'),
1229 + 'nonce' => wp_create_nonce('metasync_nonce'),
1025 1230 ));
1026 1231 }
1027 1232
1028 - // OTTO excluded URLs
1029 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1233 + // OTTO excluded URLs (lives on the Compatibility page)
1234 + if ($current_page === self::$page_slug . '-compatibility') {
1030 1235 wp_enqueue_script(
1031 1236 $this->plugin_name . '-excluded-urls',
1032 1237 plugin_dir_url(__FILE__) . 'js/metasync-excluded-urls.js',
1033 1238 array('jquery'),
@@ -1040,9 +1245,9 @@
1040 1245 ));
1041 1246 }
1042 1247
1043 1248 // Execution settings form
1044 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1249 + if ($current_page === self::$page_slug) {
1045 1250 $server_limits = $this->get_server_limits();
1046 1251 wp_enqueue_script(
1047 1252 $this->plugin_name . '-execution-settings',
1048 1253 plugin_dir_url(__FILE__) . 'js/metasync-execution-settings.js',
@@ -1057,18 +1262,21 @@
1057 1262 ));
1058 1263 }
1059 1264
1060 1265 // Quick edit badge (custom pages)
1061 - wp_enqueue_script(
1062 - $this->plugin_name . '-quick-edit',
1063 - plugin_dir_url(__FILE__) . 'js/metasync-quick-edit.js',
1064 - array('jquery'),
1065 - $this->version,
1066 - true
1067 - );
1068 - wp_localize_script($this->plugin_name . '-quick-edit', 'metasyncQuickEditData', array(
1069 - 'standardPageLabel' => __('Standard page', 'metasync'),
1070 - ));
1266 + global $pagenow;
1267 + if ($pagenow === 'edit.php') {
1268 + wp_enqueue_script(
1269 + $this->plugin_name . '-quick-edit',
1270 + plugin_dir_url(__FILE__) . 'js/metasync-quick-edit.js',
1271 + array('jquery'),
1272 + $this->version,
1273 + true
1274 + );
1275 + wp_localize_script($this->plugin_name . '-quick-edit', 'metasyncQuickEditData', array(
1276 + 'standardPageLabel' => __('Standard page', 'metasync'),
1277 + ));
1278 + }
1071 1279
1072 1280 // Report issue form
1073 1281 if ($current_page === self::$page_slug . '-report-issue') {
1074 1282 wp_enqueue_script(
@@ -1088,8 +1296,11 @@
1088 1296 array('jquery'),
1089 1297 $this->version,
1090 1298 true
1091 1299 );
1300 + wp_localize_script($this->plugin_name . '-bing-console', 'metasyncBingConsoleData', array(
1301 + 'nonce' => wp_create_nonce('metasync_nonce'),
1302 + ));
1092 1303 }
1093 1304
1094 1305 // Add redirection form
1095 1306 if ($current_page === self::$page_slug . '-redirections') {
@@ -1117,9 +1328,9 @@
1117 1328 ));
1118 1329 }
1119 1330
1120 1331 // Import external data (SEO metadata)
1121 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1332 + if ($current_page === self::$page_slug . '-import-external') {
1122 1333 wp_enqueue_script(
1123 1334 $this->plugin_name . '-import-external-data',
1124 1335 $plugin_root_url . 'views/js/metasync-import-external-data.js',
1125 1336 array('jquery'),
@@ -1132,9 +1343,9 @@
1132 1343 ));
1133 1344 }
1134 1345
1135 1346 // OTTO bot statistics
1136 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1347 + if ($current_page === self::$page_slug . '-bot-statistics') {
1137 1348 wp_enqueue_script(
1138 1349 $this->plugin_name . '-bot-statistics',
1139 1350 $plugin_root_url . 'views/js/metasync-bot-statistics.js',
1140 1351 array('jquery'),
@@ -1161,105 +1372,108 @@
1161 1372 }
1162 1373
1163 1374 // --- End Phase 5 enqueues ---
1164 1375
1165 - # Localize theme switcher script
1166 - wp_localize_script(
1167 - $this->plugin_name . '-theme-switcher',
1168 - 'metasyncThemeData',
1169 - array(
1170 - 'ajaxUrl' => admin_url('admin-ajax.php'),
1171 - 'nonce' => wp_create_nonce('metasync_theme_nonce'),
1172 - 'currentTheme' => get_option('metasync_theme', 'dark')
1173 - )
1174 - );
1376 + if ($is_metasync_page) {
1377 + # Localize theme switcher script
1378 + wp_localize_script(
1379 + $this->plugin_name . '-theme-switcher',
1380 + 'metasyncThemeData',
1381 + array(
1382 + 'ajaxUrl' => admin_url('admin-ajax.php'),
1383 + 'nonce' => wp_create_nonce('metasync_theme_nonce'),
1384 + 'currentTheme' => get_option('metasync_theme', 'dark')
1385 + )
1386 + );
1175 1387
1176 - // Localize the script to make the AJAX URL accessible
1177 - $options = Metasync::get_option('general');
1178 - // Get connection status for JavaScript
1179 - $general_settings = Metasync::get_option('general');
1180 - $searchatlas_api_key = isset($general_settings['searchatlas_api_key']) ? $general_settings['searchatlas_api_key'] : '';
1181 - $otto_pixel_uuid = isset($general_settings['otto_pixel_uuid']) ? $general_settings['otto_pixel_uuid'] : '';
1182 -
1183 - // SECURITY FIX (CVE-2025-14386): Only generate Search Atlas connect nonce for administrators
1184 - // Using strict capability check instead of plugin access roles
1185 - $sa_connect_nonce = '';
1186 - if (current_user_can('manage_options')) {
1187 - $sa_connect_nonce = wp_create_nonce('metasync_sa_connect_nonce');
1388 + // Get connection status for JavaScript
1389 + $general_settings = Metasync::get_option('general');
1390 + $searchatlas_api_key = isset($general_settings['searchatlas_api_key']) ? $general_settings['searchatlas_api_key'] : '';
1391 + $otto_pixel_uuid = isset($general_settings['otto_pixel_uuid']) ? $general_settings['otto_pixel_uuid'] : '';
1392 +
1393 + // SECURITY FIX (CVE-2025-14386): Only generate Search Atlas connect nonce for administrators
1394 + // Using strict capability check instead of plugin access roles
1395 + $sa_connect_nonce = '';
1396 + if (current_user_can('manage_options')) {
1397 + $sa_connect_nonce = wp_create_nonce('metasync_sa_connect_nonce');
1398 + }
1399 +
1400 + $heartbeat_state = $this->get_heartbeat_state();
1401 + wp_localize_script( $this->plugin_name, 'metaSync', array(
1402 + 'ajax_url' => admin_url( 'admin-ajax.php' ),
1403 + 'admin_url'=>admin_url('admin.php'),
1404 + 'nonce' => wp_create_nonce('metasync_nonce'),
1405 + 'sa_connect_nonce' => $sa_connect_nonce,
1406 + 'reset_auth_nonce' => wp_create_nonce('metasync_reset_auth_nonce'),
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'),
1410 + 'heartbeat_state' => $heartbeat_state,
1411 + 'dashboard_domain' => self::get_effective_dashboard_domain(),
1412 + 'support_email' => Metasync::SUPPORT_EMAIL,
1413 + 'documentation_domain' => Metasync::DOCUMENTATION_DOMAIN,
1414 + 'debug_enabled' => WP_DEBUG || (defined('METASYNC_DEBUG') && constant('METASYNC_DEBUG')),
1415 + 'searchatlas_api_key' => !empty($searchatlas_api_key),
1416 + 'otto_pixel_uuid' => $otto_pixel_uuid,
1417 + 'is_connected' => (bool)$this->is_heartbeat_connected()
1418 + ));
1419 +
1420 + // Ensure ajaxurl is available for admin pages (WordPress standard)
1421 + // This creates a global ajaxurl variable for JavaScript
1422 + wp_enqueue_script('wp-util');
1423 +
1424 + // Add inline script to ensure ajaxurl is defined
1425 + $inline_script = "
1426 + if (typeof ajaxurl === 'undefined') {
1427 + var ajaxurl = '" . esc_js(admin_url('admin-ajax.php')) . "';
1428 + }
1429 +
1430 + // Add Plugin Auth Token refresh functionality
1431 + jQuery(document).ready(function($) {
1432 + $('#refresh-plugin-auth-token').click(function() {
1433 + var button = $(this);
1434 + var originalText = button.text();
1435 +
1436 + if (confirm('Are you sure you want to refresh the Plugin Auth Token? This will generate a new token and update the heartbeat API.')) {
1437 + // Disable button and show loading
1438 + button.prop('disabled', true).text('🔄 Refreshing...');
1439 +
1440 + $.post(ajaxurl, {
1441 + action: 'metasync_refresh_plugin_auth_token',
1442 + nonce: '" . wp_create_nonce('metasync_refresh_plugin_auth_token') . "'
1443 + })
1444 + .done(function(response) {
1445 + if (response.success && response.data && response.data.new_token) {
1446 + // Update the field value immediately
1447 + $('#apikey').val(response.data.new_token);
1448 +
1449 + // Visual feedback with green border
1450 + $('#apikey').css('border', '2px solid #28a745').animate({borderColor: '#ddd'}, 2000);
1451 +
1452 + alert('✅ Plugin Auth Token refreshed successfully!\\n\\nNew token: ' + response.data.new_token.substring(0, 8) + '...');
1453 + } else {
1454 + alert('❌ Error refreshing token: ' + (response.data ? response.data.message : 'Unknown error'));
1455 + }
1456 + })
1457 + .fail(function() {
1458 + alert('❌ Network error while refreshing token');
1459 + })
1460 + .always(function() {
1461 + // Re-enable button
1462 + button.prop('disabled', false).text(originalText);
1463 + });
1464 + }
1465 + });
1466 + });
1467 + ";
1468 + wp_add_inline_script($this->plugin_name, $inline_script);
1188 1469 }
1189 -
1190 - $heartbeat_state = $this->get_heartbeat_state();
1191 - wp_localize_script( $this->plugin_name, 'metaSync', array(
1192 - 'ajax_url' => admin_url( 'admin-ajax.php' ),
1193 - 'admin_url'=>admin_url('admin.php'),
1194 - 'sa_connect_nonce' => $sa_connect_nonce,
1195 - 'reset_auth_nonce' => wp_create_nonce('metasync_reset_auth_nonce'),
1196 - 'burst_ping_nonce' => wp_create_nonce('metasync_burst_ping'),
1197 - 'heartbeat_state' => $heartbeat_state,
1198 - 'dashboard_domain' => self::get_effective_dashboard_domain(),
1199 - 'support_email' => Metasync::SUPPORT_EMAIL,
1200 - 'documentation_domain' => Metasync::DOCUMENTATION_DOMAIN,
1201 - 'debug_enabled' => WP_DEBUG || (defined('METASYNC_DEBUG') && constant('METASYNC_DEBUG')),
1202 - 'searchatlas_api_key' => !empty($searchatlas_api_key),
1203 - 'otto_pixel_uuid' => $otto_pixel_uuid,
1204 - 'is_connected' => (bool)$this->is_heartbeat_connected()
1205 - ));
1206 -
1207 - // Ensure ajaxurl is available for admin pages (WordPress standard)
1208 - // This creates a global ajaxurl variable for JavaScript
1209 - wp_enqueue_script('wp-util');
1210 -
1211 - // Add inline script to ensure ajaxurl is defined
1212 - $inline_script = "
1213 - if (typeof ajaxurl === 'undefined') {
1214 - var ajaxurl = '" . esc_js(admin_url('admin-ajax.php')) . "';
1215 - }
1216 -
1217 - // Add Plugin Auth Token refresh functionality
1218 - jQuery(document).ready(function($) {
1219 - $('#refresh-plugin-auth-token').click(function() {
1220 - var button = $(this);
1221 - var originalText = button.text();
1222 -
1223 - if (confirm('Are you sure you want to refresh the Plugin Auth Token? This will generate a new token and update the heartbeat API.')) {
1224 - // Disable button and show loading
1225 - button.prop('disabled', true).text('🔄 Refreshing...');
1226 -
1227 - $.post(ajaxurl, {
1228 - action: 'metasync_refresh_plugin_auth_token',
1229 - nonce: '" . wp_create_nonce('metasync_refresh_plugin_auth_token') . "'
1230 - })
1231 - .done(function(response) {
1232 - if (response.success && response.data && response.data.new_token) {
1233 - // Update the field value immediately
1234 - $('#apikey').val(response.data.new_token);
1235 -
1236 - // Visual feedback with green border
1237 - $('#apikey').css('border', '2px solid #28a745').animate({borderColor: '#ddd'}, 2000);
1238 -
1239 - alert('✅ Plugin Auth Token refreshed successfully!\\n\\nNew token: ' + response.data.new_token.substring(0, 8) + '...');
1240 - } else {
1241 - alert('❌ Error refreshing token: ' + (response.data ? response.data.message : 'Unknown error'));
1242 - }
1243 - })
1244 - .fail(function() {
1245 - alert('❌ Network error while refreshing token');
1246 - })
1247 - .always(function() {
1248 - // Re-enable button
1249 - button.prop('disabled', false).text(originalText);
1250 - });
1251 - }
1252 - });
1253 - });
1254 - ";
1255 - wp_add_inline_script($this->plugin_name, $inline_script);
1256 1470 add_action('admin_notices', array($this, 'permalink_structure_dashboard_warning'));
1257 1471 add_action('admin_notices', array($this, 'display_page_builder_notice'));
1258 1472 // Display update warning banner if plugin update is available
1259 1473 add_action('admin_notices', array($this, 'display_update_warning_banner'));
1260 1474 // Enqueue wizard assets if on wizard page
1261 - 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
1262 1476 wp_enqueue_script(
1263 1477 $this->plugin_name . '-setup-wizard',
1264 1478 plugin_dir_url(__FILE__) . 'js/metasync-setup-wizard.js',
1265 1479 array('jquery'),
@@ -1283,14 +1497,44 @@
1283 1497 }
1284 1498
1285 1499 /**
1286 1500 * Settings of HeartBeat API for admin area.
1287 - * 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.
1288 1519 */
1289 1520 function metasync_heartbeat_settings($settings)
1290 1521 {
1291 - global $heartbeat_frequency;
1292 - $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;
1293 1537 return $settings;
1294 1538 }
1295 1539
1296 1540 /**
@@ -1428,13 +1672,8 @@
1428 1672 {
1429 1673 Metasync_Connect_Manager::instance()->test_searchatlas_ajax_endpoint();
1430 1674 }
1431 1675
1432 - public function simple_ajax_test()
1433 - {
1434 - Metasync_Connect_Manager::instance()->simple_ajax_test();
1435 - }
1436 -
1437 1676 /**
1438 1677 * Create Admin Dashboard Iframe Page
1439 1678 * Embeds the Search Atlas dashboard directly in WordPress admin
1440 1679 */
@@ -1459,8 +1698,47 @@
1459 1698 // Get cache count
1460 1699 $cache_count = Metasync_Otto_Transient_Cache::get_cache_count();
1461 1700
1462 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 +
1463 1741 <!-- Cache Plugin Management -->
1464 1742 <div style="margin-bottom: 30px;">
1465 1743 <h4 style="margin-top: 0; color: var(--dashboard-text-primary);">Clear All Cache Plugins</h4>
1466 1744 <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">Clear all cache plugins to ensure changes are visible immediately.</p>
@@ -1482,9 +1760,10 @@
1482 1760 } else {
1483 1761 echo '<p style="color: var(--dashboard-text-secondary);">ℹ️ No cache plugins detected.</p>';
1484 1762 }
1485 1763 } catch (Exception $e) {
1486 - echo '<p style="color: var(--dashboard-error);">⚠️ Error: ' . esc_html($e->getMessage()) . '</p>';
1764 + error_log('MetaSync Cache Status Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1765 + echo '<p style="color: var(--dashboard-error);">⚠️ An error occurred while retrieving cache plugin status.</p>';
1487 1766 }
1488 1767 } else {
1489 1768 echo '<p style="color: var(--dashboard-error);">⚠️ Cache Purge class not loaded.</p>';
1490 1769 }
@@ -1493,9 +1772,9 @@
1493 1772 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="margin-top: 15px;">
1494 1773 <input type="hidden" name="action" value="metasync_clear_all_cache_plugins" />
1495 1774 <?php wp_nonce_field('metasync_clear_cache_nonce', 'clear_cache_nonce'); ?>
1496 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)';">
1497 - <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
1498 1777 </button>
1499 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>
1500 1779 </form>
1501 1780
@@ -1500,16 +1779,16 @@
1500 1779 </form>
1501 1780
1502 1781 <?php
1503 1782 // Display success/error messages
1504 - if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') {
1505 - $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0;
1506 - $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0;
1507 - $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
1508 1787
1509 1788 if ($cleared > 0) {
1510 1789 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1511 - echo '✅ <strong>Success!</strong> Cleared cache for ' . $cleared . ' plugin(s)';
1790 + echo '✅ <strong>Success!</strong> Cleared cache for ' . intval( $cleared ) . ' plugin(s)';
1512 1791 if ($plugins) {
1513 1792 echo ': ' . esc_html($plugins);
1514 1793 }
1515 1794 echo '</p></div>';
@@ -1520,15 +1799,15 @@
1520 1799 }
1521 1800
1522 1801 if ($failed > 0) {
1523 1802 echo '<div class="notice notice-warning inline" style="margin-top: 15px;"><p>';
1524 - echo '⚠️ Failed to clear ' . $failed . ' plugin(s).';
1803 + echo '⚠️ Failed to clear ' . intval( $failed ) . ' plugin(s).';
1525 1804 echo '</p></div>';
1526 1805 }
1527 1806 }
1528 1807
1529 - if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') {
1530 - $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
1531 1810 if (empty($message)) {
1532 1811 $message = 'An unknown error occurred while clearing cache. Please check error logs for details.';
1533 1812 }
1534 1813 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
@@ -1577,8 +1856,9 @@
1577 1856 <?php checked(true, !empty($hosting_settings['wpengine_enabled'])); ?>
1578 1857 <?php echo !$wpe_detected ? 'disabled' : ''; ?>
1579 1858 style="width: 16px; height: 16px; cursor: <?php echo $wpe_detected ? 'pointer' : 'not-allowed'; ?>;" />
1580 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.'); ?>
1581 1861 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges Varnish + Memcached</span>
1582 1862 </label>
1583 1863
1584 1864 <label style="display: flex; align-items: center; gap: 10px; margin-bottom: 16px; cursor: pointer;">
@@ -1587,8 +1867,9 @@
1587 1867 <?php checked(true, !empty($hosting_settings['kinsta_enabled'])); ?>
1588 1868 <?php echo !$kinsta_detected ? 'disabled' : ''; ?>
1589 1869 style="width: 16px; height: 16px; cursor: <?php echo $kinsta_detected ? 'pointer' : 'not-allowed'; ?>;" />
1590 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.'); ?>
1591 1872 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges full-page cache (kinsta_cache_purge_full)</span>
1592 1873 </label>
1593 1874
1594 1875 <div style="display: flex; align-items: center; gap: 12px;">
@@ -1602,9 +1883,9 @@
1602 1883 </button>
1603 1884 <span id="metasync-hc-save-msg" style="display: none; font-size: 13px;"></span>
1604 1885 </div>
1605 1886
1606 - <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')); ?>" />
1607 1888 </div>
1608 1889
1609 1890 <!-- Purge button -->
1610 1891 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
@@ -1623,12 +1904,13 @@
1623 1904 </form>
1624 1905
1625 1906 <?php
1626 1907 // Hosting cache result messages
1627 - if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') {
1628 - $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode($_GET['hc_cleared'])) : '';
1629 - $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode($_GET['hc_failed'])) : '';
1630 - $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
1631 1913
1632 1914 if ($hc_cleared) {
1633 1915 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1634 1916 echo '✅ <strong>Success!</strong> Purged hosting cache on: ' . esc_html($hc_cleared);
@@ -1643,8 +1925,9 @@
1643 1925 echo '<div class="notice notice-info inline" style="margin-top: 10px;"><p>';
1644 1926 echo 'ℹ️ No enabled hosting providers were detected on this server (' . esc_html($hc_not_detected) . ').';
1645 1927 echo '</p></div>';
1646 1928 }
1929 + // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1647 1930 }
1648 1931 ?>
1649 1932
1650 1933 <script>
@@ -1679,8 +1962,46 @@
1679 1962 setTimeout(function() { $msg.fadeOut(); }, 4000);
1680 1963 }
1681 1964 });
1682 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 + });
1683 2004 });
1684 2005 </script>
1685 2006 </div>
1686 2007
@@ -1698,9 +2019,9 @@
1698 2019 id="metasync-targeted-object-cache"
1699 2020 <?php checked('1', $targeted_cache_enabled); ?>
1700 2021 style="width: 16px; height: 16px; cursor: pointer; margin-top: 2px; flex-shrink: 0;" />
1701 2022 <span>
1702 - <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>
1703 2024 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">
1704 2025 When enabled, only the updated posts are evicted from the object cache (recommended for large sites).
1705 2026 When disabled, a full <code>wp_cache_flush()</code> is used instead.
1706 2027 </span>
@@ -1771,23 +2092,23 @@
1771 2092 </div>
1772 2093
1773 2094 <?php
1774 2095 // Display success/error messages
1775 - if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') {
1776 - $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0;
1777 - $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
1778 2099
1779 2100 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1780 2101 if (!empty($url)) {
1781 - echo '✅ <strong>Success!</strong> Cleared cache for URL: <code>' . esc_html($url) . '</code> (' . $cleared_count . ' entries)';
2102 + echo '✅ <strong>Success!</strong> Cleared cache for URL: <code>' . esc_html($url) . '</code> (' . intval( $cleared_count ) . ' entries)';
1782 2103 } else {
1783 - echo '✅ <strong>Success!</strong> Cleared entire transient cache (' . $cleared_count . ' entries)';
2104 + echo '✅ <strong>Success!</strong> Cleared entire transient cache (' . intval( $cleared_count ) . ' entries)';
1784 2105 }
1785 2106 echo '</p></div>';
1786 2107 }
1787 2108
1788 - if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') {
1789 - $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
1790 2111 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
1791 2112 echo '❌ <strong>Error:</strong> ' . esc_html($message);
1792 2113 echo '</p></div>';
1793 2114 }
@@ -1826,9 +2147,9 @@
1826 2147 <td>
1827 2148 <input type="url"
1828 2149 id="otto_cache_url"
1829 2150 name="otto_cache_url"
1830 - 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 ?>"
1831 2152 class="regular-text"
1832 2153 placeholder="https://example.com/page/"
1833 2154 required />
1834 2155 <p class="description" style="color: var(--dashboard-text-secondary);">Enter the full URL of the page whose cache you want to clear.</p>
@@ -1899,9 +2220,10 @@
1899 2220 /**
1900 2221 * AJAX handler for saving hosting cache settings
1901 2222 */
1902 2223 public function ajax_save_hosting_cache_settings() {
1903 - 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')) {
1904 2226 wp_send_json_error(array('message' => 'Security check failed'));
1905 2227 return;
1906 2228 }
1907 2229
@@ -1922,9 +2244,10 @@
1922 2244 /**
1923 2245 * AJAX handler: save object cache behaviour settings
1924 2246 */
1925 2247 public function ajax_save_object_cache_settings() {
1926 - 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')) {
1927 2250 wp_send_json_error(array('message' => 'Security check failed'));
1928 2251 return;
1929 2252 }
1930 2253
@@ -1938,12 +2261,52 @@
1938 2261 wp_send_json_success(array('message' => 'Object cache settings saved'));
1939 2262 }
1940 2263
1941 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 + /**
1942 2304 * admin_post handler: purge WP Engine and Kinsta hosting-level caches
1943 2305 */
1944 2306 public function handle_purge_hosting_cache() {
1945 - 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')) {
1946 2309 wp_die('Security check failed');
1947 2310 }
1948 2311
1949 2312 if (!Metasync::current_user_has_plugin_access()) {
@@ -1963,9 +2326,9 @@
1963 2326 WpeCommon::purge_varnish_cache();
1964 2327 WpeCommon::purge_memcached();
1965 2328 $cleared[] = 'WP Engine';
1966 2329 } catch (Exception $e) {
1967 - 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
1968 2331 $failed[] = 'WP Engine';
1969 2332 }
1970 2333 } else {
1971 2334 $not_detected[] = 'WP Engine';
@@ -1978,9 +2341,9 @@
1978 2341 try {
1979 2342 KinstaCache::get_instance()->kinsta_cache_purge_full();
1980 2343 $cleared[] = 'Kinsta';
1981 2344 } catch (Exception $e) {
1982 - 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
1983 2346 $failed[] = 'Kinsta';
1984 2347 }
1985 2348 } else {
1986 2349 $not_detected[] = 'Kinsta';
@@ -2375,9 +2738,9 @@
2375 2738 # Initialize MetaSync API request class and trigger synchronization
2376 2739 (new Metasync_Sync_Requests())->SyncCustomerParams();
2377 2740 } catch (Exception $e) {
2378 2741 # Log any API request errors for debugging
2379 - 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
2380 2743 }
2381 2744 }
2382 2745
2383 2746 /*
@@ -2392,9 +2755,9 @@
2392 2755 # Initialize MetaSync API request class and trigger synchronization
2393 2756 (new Metasync_Sync_Requests())->SyncCustomerParams();
2394 2757 } catch (Exception $e) {
2395 2758 # Log any API request errors for debugging
2396 - 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
2397 2760 }
2398 2761 }
2399 2762
2400 2763 /**
@@ -2427,8 +2790,15 @@
2427 2790 // Handle form submissions
2428 2791 if (isset($_POST['metasync_media_optimization_nonce'])) {
2429 2792 check_admin_referer('metasync_save_media_optimization', 'metasync_media_optimization_nonce');
2430 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 +
2431 2801 // Handle reset to defaults
2432 2802 if (!empty($_POST['metasync_media_reset'])) {
2433 2803 $defaults = Metasync_Media_Settings::get_defaults();
2434 2804 Metasync_Media_Settings::save_settings($defaults);
@@ -2433,8 +2803,9 @@
2433 2803 $defaults = Metasync_Media_Settings::get_defaults();
2434 2804 Metasync_Media_Settings::save_settings($defaults);
2435 2805 $save_success = true;
2436 2806 } elseif (isset($_POST['metasync_media'])) {
2807 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Media_Settings::save_settings().
2437 2808 $input = wp_unslash($_POST['metasync_media']);
2438 2809 Metasync_Media_Settings::save_settings($input);
2439 2810 $save_success = true;
2440 2811 }
@@ -2466,8 +2837,15 @@
2466 2837 * Code Minification page callback
2467 2838 */
2468 2839 public function create_admin_code_minification_page()
2469 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 +
2470 2848 $this->render_layout_open('Code Minification', 'code_minification', 'Minify CSS, JavaScript, and HTML to improve performance.');
2471 2849 // Load settings and compatibility classes
2472 2850 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-settings.php';
2473 2851 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-cache.php';
@@ -2484,8 +2862,9 @@
2484 2862 $defaults = Metasync_Minification_Settings::get_defaults();
2485 2863 Metasync_Minification_Settings::save_settings($defaults);
2486 2864 $save_success = true;
2487 2865 } elseif (isset($_POST['metasync_code_min'])) {
2866 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Minification_Settings::save_settings().
2488 2867 $input = (array) wp_unslash($_POST['metasync_code_min']);
2489 2868 Metasync_Minification_Settings::save_settings($input);
2490 2869 $save_success = true;
2491 2870 }
@@ -2518,11 +2897,40 @@
2518 2897 if (!$attachment_id) {
2519 2898 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2520 2899 }
2521 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 +
2522 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';
2523 2910 $settings = Metasync_Media_Settings::get_settings();
2524 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 +
2525 2933 $success = Metasync_Image_Converter::convert_attachment($attachment_id, $settings);
2526 2934
2527 2935 if ($success) {
2528 2936 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
@@ -2527,12 +2935,13 @@
2527 2935 if ($success) {
2528 2936 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
2529 2937 wp_send_json_success([
2530 2938 'status_html' => Metasync_Media_Library_List_Table::render_status_html($attachment_id),
2939 + 'can_revert' => Metasync_Image_Converter::can_revert($attachment_id),
2531 2940 ]);
2532 2941 }
2533 2942
2534 - wp_send_json_error(__('Optimization failed.', 'metasync'));
2943 + wp_send_json_error(__('Optimization failed: conversion could not be completed.', 'metasync'));
2535 2944 }
2536 2945
2537 2946 /**
2538 2947 * AJAX: Revert a single image.
@@ -2549,8 +2958,20 @@
2549 2958 if (!$attachment_id) {
2550 2959 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2551 2960 }
2552 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 +
2553 2974 $success = Metasync_Image_Converter::revert_attachment($attachment_id);
2554 2975
2555 2976 if ($success) {
2556 2977 wp_send_json_success();
@@ -2602,8 +3023,15 @@
2602 3023 public function ajax_batch_progress()
2603 3024 {
2604 3025 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
2605 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 +
2606 3034 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-batch-optimizer.php';
2607 3035 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
2608 3036
2609 3037 $progress = Metasync_Media_Batch_Optimizer::get_progress();
@@ -2634,10 +3062,19 @@
2634 3062 $settings = Metasync_Media_Settings::get_settings();
2635 3063
2636 3064 $success = 0;
2637 3065 $failed = 0;
3066 + $denied = 0;
2638 3067
2639 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 +
2640 3077 if (Metasync_Image_Converter::convert_attachment($id, $settings)) {
2641 3078 $success++;
2642 3079 } else {
2643 3080 $failed++;
@@ -2646,12 +3083,81 @@
2646 3083
2647 3084 wp_send_json_success([
2648 3085 'success' => $success,
2649 3086 'failed' => $failed,
3087 + 'denied' => $denied,
2650 3088 ]);
2651 3089 }
2652 3090
2653 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 + /**
2654 3160 * AJAX: Process one batch tick (browser-driven chaining).
2655 3161 */
2656 3162 public function ajax_process_batch_tick()
2657 3163 {
@@ -2670,8 +3176,49 @@
2670 3176 wp_send_json_success($progress);
2671 3177 }
2672 3178
2673 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 + /**
2674 3221 * Cron handler: Process batch optimization tick.
2675 3222 */
2676 3223 public function handle_media_batch_cron()
2677 3224 {
@@ -2681,8 +3228,86 @@
2681 3228 Metasync_Media_Batch_Optimizer::process_batch_tick();
2682 3229 }
2683 3230
2684 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 + /**
2685 3310 * Report Issue page callback
2686 3311 */
2687 3312 public function create_admin_report_issue_page()
2688 3313 {
@@ -2694,8 +3319,17 @@
2694 3319 * XML Sitemap page callback
2695 3320 */
2696 3321 public function create_admin_xml_sitemap_page()
2697 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 +
2698 3332 // Load sitemap generator class
2699 3333 require_once plugin_dir_path(dirname(__FILE__)) . 'sitemap/class-metasync-sitemap-generator.php';
2700 3334
2701 3335 $sitemap_generator = new Metasync_Sitemap_Generator();
@@ -2707,17 +3341,56 @@
2707 3341 } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) {
2708 3342 $active_tab = sanitize_text_field(wp_unslash($_POST['redirect_tab']));
2709 3343 }
2710 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 +
2711 3374 // Handle News Sitemap settings form submission
2712 3375 if (isset($_POST['metasync_news_sitemap_nonce']) && isset($_POST['save_news_sitemap'])) {
2713 3376 check_admin_referer('metasync_news_sitemap_action', 'metasync_news_sitemap_nonce');
2714 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 +
2715 3386 $news_settings = [
2716 3387 'enabled' => isset($_POST['news_enabled']),
2717 3388 'post_types' => array_map('sanitize_key', (array) ($_POST['news_post_types'] ?? ['post'])),
2718 3389 'categories' => array_map('absint', (array) ($_POST['news_categories'] ?? [])),
2719 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'] ?? '')),
2720 3393 'publication_name' => sanitize_text_field(wp_unslash($_POST['publication_name'] ?? '')),
2721 3394 'publication_language' => sanitize_text_field(wp_unslash($_POST['publication_language'] ?? '')),
2722 3395 ];
2723 3396
@@ -2727,13 +3400,14 @@
2727 3400 update_option('metasync_news_sitemap_settings', $news_settings);
2728 3401
2729 3402 if ($news_settings['enabled']) {
2730 3403 $sitemap_generator->generate_news_sitemap();
3404 + update_option('metasync_sitemap_last_generated', current_time('mysql'));
2731 3405 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
2732 3406 } else {
2733 3407 // Also remove physical file if it exists
2734 3408 if (file_exists(ABSPATH . 'news-sitemap.xml')) {
2735 - @unlink(ABSPATH . 'news-sitemap.xml');
3409 + @wp_delete_file(ABSPATH . 'news-sitemap.xml');
2736 3410 }
2737 3411 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
2738 3412 }
2739 3413 }
@@ -2741,12 +3415,26 @@
2741 3415 // Handle Video Sitemap settings form submission
2742 3416 if (isset($_POST['metasync_video_sitemap_nonce']) && isset($_POST['save_video_sitemap'])) {
2743 3417 check_admin_referer('metasync_video_sitemap_action', 'metasync_video_sitemap_nonce');
2744 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 +
2745 3427 $video_settings = [
2746 - 'enabled' => isset($_POST['video_enabled']),
2747 - 'post_types' => array_map('sanitize_key', (array) ($_POST['video_post_types'] ?? ['post', 'page'])),
2748 - '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'] ?? '')),
2749 3437 ];
2750 3438
2751 3439 // Always invalidate old cache before saving new settings
2752 3440 delete_transient('metasync_vsm_' . md5('video-sitemap.xml'));
@@ -2754,13 +3442,14 @@
2754 3442 update_option('metasync_video_sitemap_settings', $video_settings);
2755 3443
2756 3444 if ($video_settings['enabled']) {
2757 3445 $sitemap_generator->generate_video_sitemap();
3446 + update_option('metasync_sitemap_last_generated', current_time('mysql'));
2758 3447 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
2759 3448 } else {
2760 3449 // Also remove physical file if it exists
2761 3450 if (file_exists(ABSPATH . 'video-sitemap.xml')) {
2762 - @unlink(ABSPATH . 'video-sitemap.xml');
3451 + @wp_delete_file(ABSPATH . 'video-sitemap.xml');
2763 3452 }
2764 3453 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
2765 3454 }
2766 3455 }
@@ -2803,8 +3492,17 @@
2803 3492 if ($sitemap_generator->generate_news_sitemap()) {
2804 3493 $extras[] = 'news';
2805 3494 }
2806 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 +
2807 3505 if (!empty($video_opts['enabled'])) {
2808 3506 if ($sitemap_generator->generate_video_sitemap()) {
2809 3507 $extras[] = 'video';
2810 3508 }
@@ -2809,17 +3507,30 @@
2809 3507 $extras[] = 'video';
2810 3508 }
2811 3509 }
2812 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 +
2813 3516 // Generate main sitemap (its index will include news/video since they now exist)
2814 3517 $result = $sitemap_generator->generate_sitemap();
2815 3518
2816 3519 if (is_wp_error($result)) {
2817 - 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>';
2818 3528 } else {
2819 3529 $message = esc_html__('Sitemap generated successfully!', 'metasync');
2820 3530 if (!empty($extras)) {
2821 3531 $message .= ' ' . sprintf(
3532 + /* translators: %s: list of sitemap types. */
2822 3533 esc_html__('Also generated %s sitemap(s).', 'metasync'),
2823 3534 implode(' & ', $extras)
2824 3535 );
2825 3536 }
@@ -2848,8 +3559,38 @@
2848 3559 echo '<div class="notice notice-success"><p>' . esc_html__('Auto-update enabled!', 'metasync') . '</p></div>';
2849 3560 } elseif (isset($_POST['disable_auto_update'])) {
2850 3561 update_option('metasync_sitemap_auto_update', false);
2851 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 + }
2852 3593 } elseif (isset($_POST['delete_sitemap'])) {
2853 3594 // Delete all sitemaps: main + news + video (handled by delete_sitemap)
2854 3595 $deleted = $sitemap_generator->delete_sitemap();
2855 3596
@@ -2855,8 +3596,10 @@
2855 3596
2856 3597 if ($deleted) {
2857 3598 // Also disable auto-update when deleting
2858 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');
2859 3602 echo '<div class="notice notice-success"><p>' . esc_html__('All sitemaps deleted successfully!', 'metasync') . '</p></div>';
2860 3603 } else {
2861 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>';
2862 3605 }
@@ -2878,8 +3621,17 @@
2878 3621 $last_generated = $sitemap_generator->get_last_generated_time();
2879 3622 $auto_update_enabled = get_option('metasync_sitemap_auto_update', false);
2880 3623 $active_sitemap_plugins = $sitemap_generator->check_active_sitemap_plugins();
2881 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 +
2882 3634 // News and video sitemap settings for tabs
2883 3635 $news_settings = get_option('metasync_news_sitemap_settings', [
2884 3636 'enabled' => false,
2885 3637 'post_types' => ['post'],
@@ -2884,15 +3636,19 @@
2884 3636 'enabled' => false,
2885 3637 'post_types' => ['post'],
2886 3638 'categories' => [],
2887 3639 'tags' => [],
3640 + 'taxonomies' => [],
3641 + 'excluded_urls' => '',
2888 3642 'publication_name' => '',
2889 3643 'publication_language' => '',
2890 3644 ]);
2891 3645 $video_settings = get_option('metasync_video_sitemap_settings', [
2892 - 'enabled' => false,
2893 - 'post_types' => ['post', 'page'],
2894 - 'auto_detect' => true,
3646 + 'enabled' => false,
3647 + 'post_types' => ['post', 'page'],
3648 + 'auto_detect' => true,
3649 + 'taxonomies' => [],
3650 + 'excluded_urls' => '',
2895 3651 ]);
2896 3652
2897 3653 // Load view
2898 3654 require_once plugin_dir_path(dirname(__FILE__)) . 'views/metasync-xml-sitemap.php';
@@ -2963,47 +3719,8 @@
2963 3719 Metasync_Admin_Pages::get_instance($this)->create_admin_breadcrumbs_page();
2964 3720 }
2965 3721
2966 3722 /**
2967 - * Google Instant Index Setting page callback
2968 - */
2969 - public function create_admin_google_instant_index_page()
2970 - {
2971 - $this->render_layout_open('Instant Indexing', 'instant_index', 'Submit URLs to Google for instant indexing via the Indexing API.');
2972 -
2973 - // Render shared Google Index credentials section
2974 - if (!function_exists('google_index_direct')) {
2975 - if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) {
2976 - require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
2977 - } else {
2978 - error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php');
2979 - return;
2980 - }
2981 - }
2982 - $google_index = google_index_direct();
2983 - $service_info = $google_index->get_service_account_info();
2984 - $is_configured = !isset($service_info['error']);
2985 -
2986 - $saved_json_display = $is_configured ? $google_index->get_redacted_config_json() : '';
2987 -
2988 - include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-index-api-settings.php';
2989 -
2990 - // Render post types selection with save form
2991 - $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
2992 - $post_types_settings = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
2993 - ?>
2994 - <form method="POST" action="">
2995 - <?php include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-instant-post-types.php'; ?>
2996 - <div class="dashboard-card" style="padding: 20px;">
2997 - <?php submit_button('Save Post Types', 'primary', 'submit', false, array('class' => 'button button-primary')); ?>
2998 - </div>
2999 - </form>
3000 - <?php
3001 -
3002 - $this->render_layout_close();
3003 - }
3004 -
3005 - /**
3006 3723 * Google Console page callback
3007 3724 */
3008 3725 public function create_admin_google_console_page()
3009 3726 {
@@ -3026,16 +3743,8 @@
3026 3743 $this->render_layout_close();
3027 3744 }
3028 3745
3029 3746 /**
3030 - * General Options page callback
3031 - */
3032 - public function create_admin_optimal_settings_page()
3033 - {
3034 - Metasync_Admin_Pages::get_instance($this)->create_admin_optimal_settings_page();
3035 - }
3036 -
3037 - /**
3038 3747 * Global Options page callback
3039 3748 */
3040 3749 public function create_admin_global_settings_page()
3041 3750 {
@@ -3067,16 +3776,8 @@
3067 3776 Metasync_Admin_Pages::get_instance($this)->create_admin_seo_controls_page();
3068 3777 }
3069 3778
3070 3779 /**
3071 - * Site Optimal Settings page callback
3072 - */
3073 - public function optimization_settings_options()
3074 - {
3075 - Metasync_Admin_Pages::get_instance($this)->optimization_settings_options();
3076 - }
3077 -
3078 - /**
3079 3780 * redirection page callback with tabs
3080 3781 */
3081 3782 public function create_admin_redirections_page()
3082 3783 {
@@ -3103,10 +3804,11 @@
3103 3804 }
3104 3805 delete_transient( Metasync_CPU_Monitor::DEFER_NOTICE_TRANSIENT );
3105 3806 echo '<div class="notice notice-warning is-dismissible"><p>';
3106 3807 printf(
3107 - /* translators: 1: current load, 2: threshold, 3: core count */
3108 - 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() ),
3109 3811 esc_html( $data['load'] ),
3110 3812 esc_html( $data['threshold'] ),
3111 3813 esc_html( $data['cores'] )
3112 3814 );
@@ -3124,9 +3826,10 @@
3124 3826 if (!get_transient('metasync_llms_conflict')) {
3125 3827 return;
3126 3828 }
3127 3829 echo '<div class="notice notice-info is-dismissible"><p>';
3128 - 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()));
3129 3832 echo '</p></div>';
3130 3833 }
3131 3834
3132 3835 /**
@@ -3195,8 +3898,17 @@
3195 3898 public function ajax_restore_robots_backup()
3196 3899 {
3197 3900 Metasync_Admin_Ajax::instance()->ajax_restore_robots_backup();
3198 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 +
3199 3911 public function ajax_create_redirect_from_404()
3200 3912 {
3201 3913 Metasync_Admin_Ajax::instance()->ajax_create_redirect_from_404();
3202 3914 }
@@ -3308,17 +4020,17 @@
3308 4020 return;
3309 4021 }
3310 4022
3311 4023 // Get pagination parameters
3312 - $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
3313 - $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);
3314 4026 $offset = ($page - 1) * $per_page;
3315 4027
3316 4028 // Get filters
3317 4029 $filters = [
3318 4030 // UI exposes date_range and status only. We compute date_from/date_to based on date_range
3319 - 'date_range' => isset($_GET['date_range']) ? sanitize_text_field(wp_unslash($_GET['date_range'])) : '',
3320 - '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
3321 4033 ];
3322 4034
3323 4035 // Map date_range to concrete date_from/date_to for DB queries
3324 4036 $date_range = $filters['date_range'];
@@ -3327,28 +4039,28 @@
3327 4039 $date_to = '';
3328 4040
3329 4041 if (!empty($date_range)) {
3330 4042 // End boundary is now by default
3331 - $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);
3332 4044
3333 4045 if ($date_range === 'today') {
3334 4046 $start_ts = strtotime('today', $wp_now_ts);
3335 - $date_from = date('Y-m-d H:i:s', $start_ts);
4047 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3336 4048 } elseif ($date_range === 'yesterday') {
3337 4049 $start_ts = strtotime('yesterday', $wp_now_ts);
3338 4050 $end_ts = strtotime('today', $wp_now_ts) - 1; // end of yesterday
3339 - $date_from = date('Y-m-d H:i:s', $start_ts);
3340 - $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);
3341 4053 } elseif ($date_range === 'this_week') {
3342 4054 $start_of_week = (int) get_option('start_of_week', 1); // 0=Sun, 1=Mon
3343 - $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
3344 4056 // Convert start_of_week to PHP's 0..6 where 0=Sunday
3345 4057 $delta_days = ($day_of_week - $start_of_week + 7) % 7;
3346 4058 $start_ts = strtotime('-' . $delta_days . ' days', strtotime('today', $wp_now_ts));
3347 - $date_from = date('Y-m-d H:i:s', $start_ts);
4059 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3348 4060 } elseif ($date_range === 'this_month') {
3349 - $start_ts = strtotime(date('Y-m-01 00:00:00', $wp_now_ts));
3350 - $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);
3351 4063 } elseif ($date_range === 'all') {
3352 4064 // no bounds
3353 4065 }
3354 4066 }
@@ -3375,12 +4087,24 @@
3375 4087 ?>
3376 4088 <div class="dashboard-card">
3377 4089 <div class="sync-log-header">
3378 4090 <div class="sync-log-title-section">
3379 - <h2>Changes Log</h2>
3380 - <p style="color: var(--dashboard-text-secondary); margin-bottom: 0;">
3381 - Recent content synchronizations from external tools.
3382 - <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>
3383 4107 </p>
3384 4108 </div>
3385 4109
3386 4110 <!-- Filters + Clear Log button - Right aligned -->
@@ -3389,10 +4113,10 @@
3389 4113 style="background:#dc3545;color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:13px;"
3390 4114 data-nonce="<?php echo esc_attr(wp_create_nonce('metasync_clear_sync_log')); ?>">
3391 4115 🗑 Clear Log
3392 4116 </button>
3393 - <form method="get" class="sync-filters-form" onchange="this.submit()">
3394 - <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 ?>">
3395 4119
3396 4120 <select name="date_range" class="sync-filter-select">
3397 4121 <option value="all" <?php selected($filters['date_range'] ?? 'all', 'all'); ?>> All Time</option>
3398 4122 <option value="today" <?php selected($filters['date_range'] ?? '', 'today'); ?>>Today</option>
@@ -3414,9 +4138,9 @@
3414 4138 <div class="sync-log-list">
3415 4139 <?php if (empty($sync_records)): ?>
3416 4140 <div class="sync-log-empty">
3417 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>
3418 - <h3>No sync records found</h3>
4142 + <h2>No sync records found</h2>
3419 4143 <p>Sync records will appear here when content/pages receive new updates.</p>
3420 4144 </div>
3421 4145 <?php else: ?>
3422 4146 <?php foreach ($sync_records as $record): ?>
@@ -3433,9 +4157,9 @@
3433 4157 <a href="<?php echo esc_url($record->url); ?>" target="_blank" rel="noopener" title="Open URL" style="margin-left:8px; text-decoration:none;"><span class="dashicons dashicons-external" style="font-size:14px;width:14px;height:14px;vertical-align:middle;"></span></a>
3434 4158 <?php endif; ?>
3435 4159 </div>
3436 4160 <div class="sync-log-meta">
3437 - <?php echo $this->time_elapsed_string($record->created_at); ?>
4161 + <?php echo esc_html( $this->time_elapsed_string($record->created_at) ); ?>
3438 4162 <?php if (!empty($record->source)): ?>
3439 4163 &nbsp;·&nbsp;<span style="opacity:.7;"><?php echo esc_html($record->source); ?></span>
3440 4164 <?php endif; ?>
3441 4165 </div>
@@ -3441,19 +4165,20 @@
3441 4165 </div>
3442 4166 </div>
3443 4167
3444 4168 <div class="sync-log-status" style="display:flex;align-items:center;gap:8px;">
3445 - <?php if ($record->status === 'published' || $record->status === 'publish'): ?>
3446 - <span class="sync-status-badge sync-status-published">
3447 - <span class="sync-status-icon">✓</span>
3448 - Published
3449 - </span>
3450 - <?php else: ?>
3451 - <span class="sync-status-badge sync-status-draft">
3452 - <span class="sync-status-icon">i</span>
3453 - Draft
3454 - </span>
3455 - <?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>
3456 4181 <?php if ($record->source === 'MCP Client'): ?>
3457 4182 <button type="button"
3458 4183 class="metasync-rollback-btn"
3459 4184 data-id="<?php echo esc_attr($record->id); ?>"
@@ -3469,30 +4194,32 @@
3469 4194 <?php endif; ?>
3470 4195 </div>
3471 4196
3472 4197 <!-- Pagination -->
3473 - <?php if ($total_pages > 1): ?>
3474 - <div class="sync-log-pagination">
3475 - <div class="sync-log-pagination-info">
3476 - Total records: <?php echo $total_records; ?> | Showing <?php echo $offset + 1; ?>-<?php echo min($offset + $per_page, $total_records); ?>
3477 - </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>
3478 4204
4205 + <?php if ($total_pages > 1): ?>
3479 4206 <div class="sync-log-pagination-controls">
3480 4207 <?php if ($page > 1): ?>
3481 - <a href="?page=<?php echo esc_attr($_GET['page']); ?>&paged=<?php echo $page - 1; ?><?php echo $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>
3482 4209 <?php endif; ?>
3483 4210
3484 4211 <?php for ($i = max(1, $page - 2); $i <= min($total_pages, $page + 2); $i++): ?>
3485 - <a href="?page=<?php echo esc_attr($_GET['page']); ?>&paged=<?php echo $i; ?><?php echo $this->build_filter_query_string($filters); ?>"
3486 - class="sync-pagination-btn <?php echo $i === $page ? 'active' : ''; ?>"><?php echo $i; ?></a>
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) ); ?>"
4213 + class="sync-pagination-btn <?php echo $i === $page ? 'active' : ''; ?>"><?php echo intval( $i ); ?></a>
3487 4214 <?php endfor; ?>
3488 4215
3489 4216 <?php if ($page < $total_pages): ?>
3490 - <a href="?page=<?php echo esc_attr($_GET['page']); ?>&paged=<?php echo $page + 1; ?><?php echo $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>
3491 4218 <?php endif; ?>
3492 4219 </div>
3493 - </div>
3494 - <?php endif; ?>
4220 + <?php endif; ?>
4221 + </div>
3495 4222 </div>
3496 4223 <?php $this->render_layout_close(); ?>
3497 4224
3498 4225 <script>
@@ -3573,8 +4300,19 @@
3573 4300 if (!empty($value)) {
3574 4301 $query_parts[] = $key . '=' . urlencode($value);
3575 4302 }
3576 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 +
3577 4315 return !empty($query_parts) ? '&' . implode('&', $query_parts) : '';
3578 4316 }
3579 4317
3580 4318 /**
@@ -3748,18 +4486,8 @@
3748 4486 {
3749 4487 Metasync_Connect_Manager::instance()->handle_session_management_early();
3750 4488 }
3751 4489
3752 - /**
3753 - * @deprecated 2.5.12 Use Metasync_Auth_Manager instead of sessions for authentication
3754 - */
3755 - private function safe_session_start() {
3756 - // This method is deprecated and no longer used
3757 - // Authentication now uses Metasync_Auth_Manager with WordPress transients and user meta
3758 - _deprecated_function(__METHOD__, '2.5.12', 'Metasync_Auth_Manager');
3759 - return Metasync_Session_Helper::safe_start();
3760 - }
3761 -
3762 4490 private function handle_whitelabel_session_logic()
3763 4491 {
3764 4492 Metasync_Connect_Manager::instance()->handle_whitelabel_session_logic();
3765 4493 }
@@ -3805,8 +4533,9 @@
3805 4533 $stats = Metasync_CPU_Monitor::get_stats();
3806 4534 $per_core_threshold = Metasync_CPU_Monitor::get_per_core_threshold();
3807 4535 $cores = Metasync_CPU_Monitor::get_cpu_core_count();
3808 4536 $effective_threshold = Metasync_CPU_Monitor::get_effective_threshold();
4537 + $detection_reliable = Metasync_CPU_Monitor::is_core_detection_reliable();
3809 4538 ?>
3810 4539 <div style="background: var(--dashboard-card-bg); padding: 20px; border-radius: 8px;">
3811 4540 <!-- CPU Cores Detected -->
3812 4541 <div style="margin-bottom: 24px;">
@@ -3813,12 +4542,20 @@
3813 4542 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
3814 4543 CPU Cores Detected
3815 4544 </label>
3816 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);">
3817 - <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; ?>
3818 4551 </div>
3819 4552 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
3820 - 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; ?>
3821 4558 </p>
3822 4559 </div>
3823 4560
3824 4561 <!-- Per-Core Load Threshold -->
@@ -3824,8 +4561,9 @@
3824 4561 <!-- Per-Core Load Threshold -->
3825 4562 <div style="margin-bottom: 24px;">
3826 4563 <label for="cpu_load_per_core_threshold" style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
3827 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.'); ?>
3828 4566 </label>
3829 4567 <input type="number"
3830 4568 id="cpu_load_per_core_threshold"
3831 4569 name="metasync_options[performance][cpu_load_per_core_threshold]"
@@ -3845,9 +4583,9 @@
3845 4583 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
3846 4584 Effective Threshold
3847 4585 </label>
3848 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);">
3849 - <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>
3850 4588 </div>
3851 4589 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
3852 4590 Calculated as: cores × per-core threshold
3853 4591 </p>
@@ -3865,13 +4603,13 @@
3865 4603 <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo intval($stats['deferrals']); ?></div>
3866 4604 </div>
3867 4605 <div>
3868 4606 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Max Load Observed</div>
3869 - <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>
3870 4608 </div>
3871 4609 <div>
3872 4610 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Average Load</div>
3873 - <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>
3874 4612 </div>
3875 4613 </div>
3876 4614 </div>
3877 4615
@@ -4377,88 +5115,8 @@
4377 5115
4378 5116 /**
4379 5117 * Get the settings option array and print one of its values
4380 5118 */
4381 - public function no_index_posts_callback()
4382 - {
4383 - Metasync_Settings_Fields::instance()->no_index_posts_callback();
4384 - }
4385 -
4386 - /**
4387 - * Get the settings option array and print one of its values
4388 - */
4389 - public function no_follow_links_callback()
4390 - {
4391 - Metasync_Settings_Fields::instance()->no_follow_links_callback();
4392 - }
4393 -
4394 - /**
4395 - * Get the settings option array and print one of its values
4396 - */
4397 - public function open_external_links_callback()
4398 - {
4399 - Metasync_Settings_Fields::instance()->open_external_links_callback();
4400 - }
4401 -
4402 - /**
4403 - * Get the settings option array and print one of its values
4404 - */
4405 - public function add_alt_image_tags_callback()
4406 - {
4407 - Metasync_Settings_Fields::instance()->add_alt_image_tags_callback();
4408 - }
4409 -
4410 - /**
4411 - * Get the settings option array and print one of its values
4412 - */
4413 - public function add_title_image_tags_callback()
4414 - {
4415 - Metasync_Settings_Fields::instance()->add_title_image_tags_callback();
4416 - }
4417 -
4418 - /**
4419 - * Get the settings option array and print one of its values
4420 - */
4421 - public function site_type_callback()
4422 - {
4423 - Metasync_Settings_Fields::instance()->site_type_callback();
4424 - }
4425 -
4426 - /**
4427 - * Get the settings option array and print one of its values
4428 - */
4429 - public function site_business_type_callback()
4430 - {
4431 - Metasync_Settings_Fields::instance()->site_business_type_callback();
4432 - }
4433 -
4434 - /**
4435 - * Get the settings option array and print one of its values
4436 - */
4437 - public function site_company_name_callback()
4438 - {
4439 - Metasync_Settings_Fields::instance()->site_company_name_callback();
4440 - }
4441 -
4442 - /**
4443 - * Get the settings option array and print one of its values
4444 - */
4445 - public function site_google_logo_callback()
4446 - {
4447 - Metasync_Settings_Fields::instance()->site_google_logo_callback();
4448 - }
4449 -
4450 - /**
4451 - * Get the settings option array and print one of its values
4452 - */
4453 - public function site_social_share_image_callback()
4454 - {
4455 - Metasync_Settings_Fields::instance()->site_social_share_image_callback();
4456 - }
4457 -
4458 - /**
4459 - * Get the settings option array and print one of its values
4460 - */
4461 5119 public function common_robot_meta_tags_callback()
4462 5120 {
4463 5121 Metasync_Settings_Fields::instance()->common_robot_meta_tags_callback();
4464 5122 }
@@ -4635,13 +5293,11 @@
4635 5293 $current_permalink_structure = get_option('permalink_structure');
4636 5294
4637 5295 # Get the plugin name using centralized method
4638 5296 $plugin_name = Metasync::get_effective_plugin_name();
4639 - $current_rewrite_rules = get_option('rewrite_rules');
5297 +
4640 5298 # Check if the current permalink structure is set to "Plain"
4641 - if (($current_permalink_structure == '/%post_id%/' || $current_permalink_structure == '') && $current_rewrite_rules == '') {
4642 -
4643 - # Show admin notice with plugin name included in the message
5299 + if ($current_permalink_structure === '/%post_id%/' || $current_permalink_structure === '') {
4644 5300 printf(
4645 5301 '<div class="notice notice-error is-dismissible">
4646 5302 <p>
4647 5303 <b>Warning from %s</b><br>
@@ -4651,9 +5307,8 @@
4651 5307 </div>',
4652 5308 esc_html($plugin_name)
4653 5309 );
4654 5310 }
4655 - flush_rewrite_rules();
4656 5311 }
4657 5312
4658 5313 /**
4659 5314 * Show a one-time admin notice when a page builder is detected but the
@@ -4659,8 +5314,14 @@
4659 5314 * Show a one-time admin notice when a page builder is detected but the
4660 5315 * "Default Page Builder" setting has never been explicitly saved.
4661 5316 */
4662 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 +
4663 5324 $configured = Metasync::get_option('general')['default_page_builder'] ?? '';
4664 5325
4665 5326 // Setting already saved — nothing to warn about
4666 5327 if (!empty($configured)) {
@@ -4673,9 +5334,9 @@
4673 5334 return;
4674 5335 }
4675 5336
4676 5337 // Handle dismiss action
4677 - 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')) {
4678 5339 update_user_meta(get_current_user_id(), 'metasync_builder_notice_dismissed', '1');
4679 5340 return;
4680 5341 }
4681 5342
@@ -4770,9 +5431,10 @@
4770 5431 * Saves the CPU load threshold and returns statistics
4771 5432 */
4772 5433 public function ajax_save_performance_settings() {
4773 5434 # Check nonce for security and return early if invalid
4774 - 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')) {
4775 5437 wp_send_json_error(array('message' => 'Invalid nonce'));
4776 5438 return;
4777 5439 }
4778 5440
@@ -4840,9 +5502,9 @@
4840 5502 if (!wp_next_scheduled('metasync_cleanup_transients')) {
4841 5503 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_cleanup_transients');
4842 5504
4843 5505 if (!$scheduled) {
4844 - 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
4845 5507 }
4846 5508 }
4847 5509 }
4848 5510
@@ -4853,9 +5515,9 @@
4853 5515 {
4854 5516 $timestamp = wp_next_scheduled('metasync_cleanup_transients');
4855 5517 if ($timestamp) {
4856 5518 wp_unschedule_event($timestamp, 'metasync_cleanup_transients');
4857 - 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
4858 5520 }
4859 5521 }
4860 5522
4861 5523 /**
@@ -4877,12 +5539,10 @@
4877 5539 {
4878 5540 if (!wp_next_scheduled('metasync_hidden_post_check')) {
4879 5541 $scheduled = wp_schedule_event(time(), 'metasync_weekly', 'metasync_hidden_post_check');
4880 5542
4881 - if ($scheduled) {
4882 - error_log('MetaSync: Hidden post manager cron job scheduled successfully (runs every 7 days)');
4883 - } else {
4884 - 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
4885 5545 }
4886 5546 }
4887 5547 }
4888 5548
@@ -4893,12 +5553,10 @@
4893 5553 public function maybe_schedule_otto_recheck_404_cron()
4894 5554 {
4895 5555 if (!wp_next_scheduled('metasync_otto_recheck_404_exclusions')) {
4896 5556 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_otto_recheck_404_exclusions');
4897 - if ($scheduled) {
4898 - error_log('MetaSync: OTTO 404 recheck cron job scheduled successfully (runs daily)');
4899 - } else {
4900 - 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
4901 5559 }
4902 5560 }
4903 5561 }
4904 5562
@@ -4971,13 +5629,13 @@
4971 5629
4972 5630 try {
4973 5631 // 1. Post revisions
4974 5632 if (!empty($settings['clean_post_revisions'])) {
4975 - $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
4976 5634 "DELETE FROM {$wpdb->posts} WHERE post_type = 'revision'"
4977 5635 );
4978 5636 // Remove postmeta left behind by deleted revisions
4979 - $wpdb->query(
5637 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
4980 5638 "DELETE pm FROM {$wpdb->postmeta} pm
4981 5639 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
4982 5640 WHERE p.ID IS NULL"
4983 5641 );
@@ -4985,20 +5643,19 @@
4985 5643
4986 5644 // 2. Trashed posts + their postmeta
4987 5645 if (!empty($settings['clean_trashed_posts'])) {
4988 5646 // Collect IDs first to cleanly remove postmeta
4989 - $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
4990 5648 "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'trash'"
4991 5649 );
4992 5650 if (!empty($trashed_ids)) {
4993 - $placeholders = implode(',', array_fill(0, count($trashed_ids), '%d'));
4994 - $wpdb->query(
5651 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
4995 5652 $wpdb->prepare(
4996 - "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')) . ')',
4997 5654 $trashed_ids
4998 5655 )
4999 5656 );
5000 - $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
5001 5658 "DELETE FROM {$wpdb->posts} WHERE post_status = 'trash'"
5002 5659 );
5003 5660 } else {
5004 5661 $stats['trashed_posts'] = 0;
@@ -5006,9 +5663,9 @@
5006 5663 }
5007 5664
5008 5665 // 3. Trashed comments
5009 5666 if (!empty($settings['clean_trashed_comments'])) {
5010 - $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
5011 5668 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'trash'"
5012 5669 );
5013 5670 }
5014 5671
@@ -5013,9 +5670,9 @@
5013 5670 }
5014 5671
5015 5672 // 4. Spam comments
5016 5673 if (!empty($settings['clean_spam_comments'])) {
5017 - $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
5018 5675 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'spam'"
5019 5676 );
5020 5677 }
5021 5678
@@ -5021,15 +5678,15 @@
5021 5678
5022 5679 // 5. Expired transients — direct SQL, no cache flush
5023 5680 if (!empty($settings['clean_expired_transients'])) {
5024 5681 // Delete timeout rows that have already expired
5025 - $wpdb->query(
5682 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5026 5683 "DELETE FROM {$wpdb->options}
5027 5684 WHERE option_name LIKE '\_transient\_timeout\_%'
5028 5685 AND option_value + 0 < UNIX_TIMESTAMP()"
5029 5686 );
5030 5687 // Delete value rows whose timeout row no longer exists
5031 - $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
5032 5689 "DELETE o FROM {$wpdb->options} o
5033 5690 LEFT JOIN {$wpdb->options} t
5034 5691 ON t.option_name = CONCAT('_transient_timeout_', SUBSTRING(o.option_name, 12))
5035 5692 WHERE o.option_name LIKE '\_transient\_%'
@@ -5039,9 +5696,9 @@
5039 5696 }
5040 5697
5041 5698 // 6. Orphaned postmeta (post_id references a post that no longer exists)
5042 5699 if (!empty($settings['clean_orphaned_postmeta'])) {
5043 - $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
5044 5701 "DELETE pm FROM {$wpdb->postmeta} pm
5045 5702 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5046 5703 WHERE p.ID IS NULL"
5047 5704 );
@@ -5053,12 +5710,10 @@
5053 5710 $settings['last_run_at'] = time();
5054 5711 $settings['last_run_stats'] = $stats;
5055 5712 update_option('metasync_db_cleanup_settings', $settings);
5056 5713
5057 - error_log('MetaSync: DB cleanup completed — ' . json_encode($stats));
5058 -
5059 5714 } catch (Exception $e) {
5060 - 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
5061 5716 }
5062 5717 }
5063 5718
5064 5719 /**
@@ -5275,10 +5930,11 @@
5275 5930 /**
5276 5931 * AJAX: Save DB cleanup settings and reschedule cron accordingly.
5277 5932 */
5278 5933 public function ajax_save_db_cleanup_settings() {
5934 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5279 5935 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5280 - !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')) {
5281 5937 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
5282 5938 return;
5283 5939 }
5284 5940
@@ -5335,10 +5991,11 @@
5335 5991 * AJAX: Manually trigger the DB cleanup and return stats for the UI.
5336 5992 * Persists current form state first so unsaved checkbox changes are respected.
5337 5993 */
5338 5994 public function ajax_run_db_cleanup() {
5995 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5339 5996 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5340 - !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')) {
5341 5998 wp_send_json_error(array('message' => 'Invalid security token.'));
5342 5999 return;
5343 6000 }
5344 6001
@@ -5414,35 +6071,8 @@
5414 6071 ));
5415 6072 }
5416 6073
5417 6074 /**
5418 - * Control plugin auto-updates based on user setting
5419 - *
5420 - * @param bool $update Whether to update
5421 - * @param object $item Update offer
5422 - * @return bool Whether to allow auto-update
5423 - */
5424 - public function control_plugin_auto_updates($update, $item)
5425 - {
5426 - # Check if the item object has the slug property
5427 - if (!isset($item->slug)) {
5428 - return $update;
5429 - }
5430 -
5431 - // Check if this is our plugin
5432 - if ($item->slug === 'metasync') {
5433 - $general_settings = Metasync::get_option('general') ?? [];
5434 - $enable_auto_updates = $general_settings['enable_auto_updates'] ?? false;
5435 -
5436 - // Return the user's preference (true = allow auto-updates, false = prevent)
5437 - return $enable_auto_updates === 'true' || $enable_auto_updates === true;
5438 - }
5439 -
5440 - // For other plugins, don't interfere with their auto-update settings
5441 - return $update;
5442 - }
5443 -
5444 - /**
5445 6075 * AJAX handler to add excluded URL for OTTO
5446 6076 */
5447 6077 public function ajax_otto_add_excluded_url()
5448 6078 {
@@ -5588,8 +6218,9 @@
5588 6218 // If page has raw HTML or custom CSS from conversion, show badge
5589 6219 if ($has_raw_html || !empty($has_custom_css)) {
5590 6220 $label = $this->get_html_source_label();
5591 6221 $tooltip = sprintf(
6222 + /* translators: %s: HTML source label. */
5592 6223 __('This page was created using %s HTML-to-Builder converter', 'metasync'),
5593 6224 $label
5594 6225 );
5595 6226
@@ -5628,8 +6259,16 @@
5628 6259 if (!$post || !in_array($post->post_type, array('post', 'page'))) {
5629 6260 return;
5630 6261 }
5631 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 +
5632 6271 $has_raw_html = get_post_meta($post->ID, '_metasync_raw_html_enabled', true);
5633 6272 $has_custom_css = get_post_meta($post->ID, '_metasync_custom_css', true);
5634 6273
5635 6274 if ($has_raw_html || !empty($has_custom_css)) {
@@ -5634,8 +6273,9 @@
5634 6273
5635 6274 if ($has_raw_html || !empty($has_custom_css)) {
5636 6275 $label = $this->get_html_source_label();
5637 6276 $message = sprintf(
6277 + /* translators: %s: HTML source label. */
5638 6278 __('This page was created using %s HTML-to-Builder converter. The design is preserved with custom CSS and inline styles.', 'metasync'),
5639 6279 '<strong>' . esc_html($label) . '</strong>'
5640 6280 );
5641 6281
@@ -5649,9 +6289,9 @@
5649 6289 <p class="metasync-editor-message">%s</p>
5650 6290 </div>
5651 6291 </div>',
5652 6292 esc_html($label),
5653 - $message
6293 + wp_kses($message, array('strong' => array()))
5654 6294 );
5655 6295 }
5656 6296 }
5657 6297
@@ -5674,9 +6314,9 @@
5674 6314 ?>
5675 6315 <fieldset class="inline-edit-col-left metasync-quick-edit-source">
5676 6316 <div class="inline-edit-col">
5677 6317 <label>
5678 - <span class="title"><?php _e('Source', 'metasync'); ?></span>
6318 + <span class="title"><?php esc_html_e('Source', 'metasync'); ?></span>
5679 6319 <span class="metasync-quick-edit-badge-container"></span>
5680 6320 </label>
5681 6321 </div>
5682 6322 </fieldset>
@@ -5688,8 +6328,9 @@
5688 6328 */
5689 6329 public function add_html_pages_dashboard_widget()
5690 6330 {
5691 6331 $label = $this->get_html_source_label();
6332 + /* translators: %s: HTML source label. */
5692 6333 $widget_title = sprintf(__('%s Pages', 'metasync'), $label);
5693 6334
5694 6335 wp_add_dashboard_widget(
5695 6336 'metasync_html_pages_widget',
@@ -5745,8 +6386,14 @@
5745 6386 * @return void Sends JSON response and exits
5746 6387 */
5747 6388 public function ajax_send_giapi()
5748 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 +
5749 6396 $post_data = metasync_sanitize_input_array($_POST);
5750 6397 if (!isset($post_data['metasync_giapi_url'])) {
5751 6398 return;
5752 6399 }
@@ -5812,8 +6459,14 @@
5812 6459 * @return void Sends JSON response and exits
5813 6460 */
5814 6461 public function ajax_send_bing_indexnow()
5815 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 +
5816 6469 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
5817 6470 $bing_instant_index = new Metasync_Bing_Instant_Index();
5818 6471 $bing_instant_index->send();
5819 6472 }
@@ -5830,8 +6483,18 @@
5830 6483 if (!isset($_POST['submit'])) {
5831 6484 return;
5832 6485 }
5833 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 +
5834 6497 // Save post types for Google Instant Indexing auto-submit
5835 6498 if (isset($_POST['metasync_post_types'])) {
5836 6499 $post_data = metasync_sanitize_input_array($_POST);
5837 6500 $post_types = is_array($post_data['metasync_post_types']) ? array_map('sanitize_title', $post_data['metasync_post_types']) : [];
@@ -5915,12 +6578,24 @@
5915 6578 if (!empty($seo_controls['enable_googleinstantindex']) && $seo_controls['enable_googleinstantindex'] === 'true') {
5916 6579 $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
5917 6580 $post_types = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
5918 6581
5919 - if (in_array($post->post_type, $post_types) && function_exists('google_index_direct')) {
5920 - $service_info = google_index_direct()->get_service_account_info();
5921 - if (!isset($service_info['error'])) {
5922 - 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 + }
5923 6598 }
5924 6599 }
5925 6600 }
5926 6601