PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | admin/class-metasync-admin.php +999 -411 2.6.9 → 2.7.2 View file →
@@ -33,9 +33,8 @@
33 33 const SECTION_METASYNC = "metasync_settings";
34 34 const SECTION_SEARCHENGINE = "searchengine_settings";
35 35 const SECTION_LOCALSEO = "local_seo";
36 36 const SECTION_CODESNIPPETS = "code_snippets";
37 - const SECTION_OPTIMAL_SETTINGS = "optimal_settings";
38 37 const SECTION_SITE_SETTINGS = "site_settings";
39 38 const SECTION_COMMON_SETTINGS = "common_settings";
40 39 const SECTION_COMMON_META_SETTINGS = "common_meta_settings";
41 40 const SECTION_SOCIAL_META = "social_meta";
@@ -42,9 +41,8 @@
42 41 const SECTION_SEO_CONTROLS = "seo_controls";
43 42 const SECTION_SEO_CONTROLS_ADVANCED = "seo_controls_advanced";
44 43 const SECTION_SEO_CONTROLS_INSTANT_INDEX = "seo_controls_instant_index";
45 44 const SECTION_PLUGIN_VISIBILITY = "plugin_visibility_settings";
46 - const SECTION_BREADCRUMBS = "breadcrumbs_settings";
47 45 const SECTION_LLMS_TXT = "llms_txt_settings";
48 46
49 47 /**
50 48 * The ID of this plugin.
@@ -198,13 +196,18 @@
198 196 $data = Metasync::get_option('general');
199 197
200 198 // Set menu title using the effective title (includes whitelabel company name if available)
201 199 $this->menu_title = $this->get_effective_menu_title();
202 - if(!isset( $data['white_label_plugin_menu_slug'])){
203 - self::$page_slug = "searchatlas";
204 - }else{
205 - self::$page_slug = $data['white_label_plugin_menu_slug']=="" ? "searchatlas":$data['white_label_plugin_menu_slug'];
206 - }
200 + $raw_slug = isset($data['white_label_plugin_menu_slug']) ? $data['white_label_plugin_menu_slug'] : '';
201 + $clean_slug = sanitize_title($raw_slug);
202 + # Self-heal a legacy URL-shaped slug: persist the sanitized value so every
203 + # reader that builds admin links from the stored option gets a valid WP menu slug.
204 + if ($raw_slug !== '' && $clean_slug !== $raw_slug) {
205 + $options = Metasync::get_option();
206 + $options['general']['white_label_plugin_menu_slug'] = $clean_slug;
207 + Metasync::set_option($options);
208 + }
209 + self::$page_slug = $clean_slug === '' ? 'searchatlas' : $clean_slug;
207 210
208 211 add_action('admin_menu', array($this, 'add_plugin_settings_page'));
209 212 add_action('admin_menu', array($this, 'add_import_external_data_page'));
210 213 add_action('admin_init', array($this, 'settings_page_init'));
@@ -209,9 +212,8 @@
209 212 add_action('admin_menu', array($this, 'add_import_external_data_page'));
210 213 add_action('admin_init', array($this, 'settings_page_init'));
211 214 add_filter('all_plugins', array($this,'metasync_plugin_white_label'));
212 215 add_filter( 'plugin_row_meta',array($this,'metasync_view_detials_url'),10,3);
213 - add_filter('site_transient_update_plugins', array($this, 'inject_whitelabel_icon_into_update_transient'));
214 216
215 217 // Display transient error/success messages for redirections
216 218 add_action('admin_notices', array($this, 'display_redirection_messages'));
217 219
@@ -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
@@ -260,8 +263,15 @@
260 263
261 264 add_action('admin_init', array($this, 'initialize_cookie'));
262 265 add_action('admin_init', array($this, 'maybe_redirect_to_wizard'));
263 266
267 + // The redirect must run on admin_menu, not admin_init: in current
268 + // WordPress the unregistered-page access check in wp-admin/includes/
269 + // menu.php fires from wp-admin/menu.php (required at admin.php:163),
270 + // which is BEFORE admin_init (admin.php:180). Hooked on admin_init the
271 + // redirect never ran — WP died with a 403 first for the removed slug.
272 + add_action('admin_menu', array($this, 'redirect_legacy_instant_indexing_page'));
273 +
264 274 // Add admin_post hooks for form submissions (WordPress standard way - no output buffering needed)
265 275 add_action('admin_post_metasync_clear_all_cache_plugins', array($this, 'handle_clear_all_cache_plugins'));
266 276 add_action('admin_post_metasync_clear_otto_cache_all', array($this, 'handle_clear_otto_cache_all'));
267 277 add_action('admin_post_metasync_clear_otto_cache_url', array($this, 'handle_clear_otto_cache_url'));
@@ -278,10 +288,16 @@
278 288
279 289 // Add AJAX for saving execution settings
280 290 add_action( 'wp_ajax_metasync_save_execution_settings', array($this, 'ajax_save_execution_settings') );
281 291
292 + // Add AJAX for saving the Headless Mode demo controls on the Advanced tab
293 + add_action( 'wp_ajax_metasync_save_headless_settings', array(Metasync_Settings_Registration::instance(), 'ajax_save_headless_settings') );
294 +
282 295 // Add AJAX for saving hosting cache settings
283 296 add_action( 'wp_ajax_metasync_save_hosting_cache_settings', array($this, 'ajax_save_hosting_cache_settings') );
297 +
298 + // Add AJAX for saving OTTO Cache TTL
299 + add_action('wp_ajax_metasync_save_otto_cache_ttl', array($this, 'ajax_save_otto_cache_ttl'));
284 300 add_action( 'wp_ajax_metasync_save_object_cache_settings', array($this, 'ajax_save_object_cache_settings') );
285 301
286 302 // Add AJAX for saving edge cache / CDN settings
287 303 add_action( 'wp_ajax_metasync_save_edge_cache_settings', array('Metasync_Edge_Cache_Settings', 'ajax_save') );
@@ -306,8 +322,9 @@
306 322 add_action('wp_ajax_metasync_get_default_robots', array($this, 'ajax_get_default_robots'));
307 323 add_action('wp_ajax_metasync_preview_robots_backup', array($this, 'ajax_preview_robots_backup'));
308 324 add_action('wp_ajax_metasync_delete_robots_backup', array($this, 'ajax_delete_robots_backup'));
309 325 add_action('wp_ajax_metasync_restore_robots_backup', array($this, 'ajax_restore_robots_backup'));
326 + add_action('wp_ajax_metasync_get_robots_backups', array($this, 'ajax_get_robots_backups'));
310 327
311 328 // Add AJAX handlers for host blocking test
312 329 add_action('wp_ajax_metasync_test_host_blocking_get', array($this, 'ajax_test_host_blocking_get'));
313 330 add_action('wp_ajax_metasync_test_host_blocking_post', array($this, 'ajax_test_host_blocking_post'));
@@ -359,11 +376,20 @@
359 376 add_action('wp_ajax_metasync_start_batch_optimize', array($this, 'ajax_start_batch_optimize'));
360 377 add_action('wp_ajax_metasync_cancel_batch_optimize', array($this, 'ajax_cancel_batch_optimize'));
361 378 add_action('wp_ajax_metasync_batch_progress', array($this, 'ajax_batch_progress'));
362 379 add_action('wp_ajax_metasync_bulk_optimize_selected', array($this, 'ajax_bulk_optimize_selected'));
380 + add_action('wp_ajax_metasync_bulk_unoptimize_selected', array($this, 'ajax_bulk_unoptimize_selected'));
363 381 add_action('wp_ajax_metasync_process_batch_tick', array($this, 'ajax_process_batch_tick'));
382 + add_action('wp_ajax_metasync_delete_orphaned_image', array($this, 'ajax_delete_orphaned_image'));
364 383 add_action('metasync_media_batch_optimize_cron', array($this, 'handle_media_batch_cron'));
365 384
385 + # SEO Restore (Rollback) AJAX & Cron handlers
386 + add_action('wp_ajax_metasync_seo_restore_start', array($this, 'ajax_seo_restore_start'));
387 + add_action('wp_ajax_metasync_seo_restore_cancel', array($this, 'ajax_seo_restore_cancel'));
388 + add_action('wp_ajax_metasync_seo_restore_progress', array($this, 'ajax_seo_restore_progress'));
389 + add_action('wp_ajax_metasync_seo_restore_process_tick', array($this, 'ajax_seo_restore_process_tick'));
390 + add_action('metasync_seo_restore_cron', array($this, 'handle_seo_restore_cron'));
391 +
366 392 # Add AJAX handlers for Google Instant Indexing
367 393 add_action('wp_ajax_metasync_send_giapi', array($this, 'ajax_send_giapi'));
368 394
369 395 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
@@ -368,11 +394,8 @@
368 394
369 395 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
370 396 add_action('wp_ajax_metasync_send_bing_indexnow', array($this, 'ajax_send_bing_indexnow'));
371 397
372 - # Add hooks for instant indexing settings saves
373 - add_action('admin_init', array($this, 'save_instant_indexing_settings'));
374 -
375 398 # Add hooks for instant indexing post actions
376 399 add_filter('post_row_actions', array($this, 'add_instant_indexing_post_actions'), 10, 2);
377 400 add_filter('page_row_actions', array($this, 'add_instant_indexing_post_actions'), 10, 2);
378 401
@@ -456,9 +479,11 @@
456 479
457 480 $data= Metasync::get_option('general');
458 481
459 482 # Get white label menu slug
460 - $menu_slug = empty($data['white_label_plugin_menu_slug']) ? self::$page_slug : $data['white_label_plugin_menu_slug'];
483 + # Sanitize so a URL-shaped legacy value still yields a valid WP menu slug
484 + $menu_slug = empty($data['white_label_plugin_menu_slug']) ? self::$page_slug : sanitize_title($data['white_label_plugin_menu_slug']);
485 + $menu_slug = $menu_slug === '' ? self::$page_slug : $menu_slug;
461 486
462 487 ?>
463 488 <style>
464 489 #toplevel_page_<?php echo esc_attr(str_replace(' ', '-',$menu_slug)); ?> .wp-menu-image.dashicons-before img {
@@ -475,16 +500,129 @@
475 500 <?php
476 501 }
477 502
478 503 public function metasync_fouc_prevention_style() {
479 - if ( ! isset( $_GET['page'] ) || strpos( $_GET['page'], self::$page_slug ) !== 0 ) {
504 + if ( ! isset( $_GET['page'] ) || strpos( sanitize_key(wp_unslash($_GET['page'])), self::$page_slug ) !== 0 ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check for an inline style
480 505 return;
481 506 }
482 507 ?>
483 - <style>.metasync-dashboard-wrap { opacity: 0; transition: opacity 0.15s ease-in; }</style>
508 + <style>
509 + .metasync-dashboard-wrap { opacity: 0; transition: opacity 0.15s ease-in; }
510 + /*
511 + * WP prints admin notices at the top of #wpbody-content, then core's
512 + * common.js relocates them next to .wp-header-end inside our wrap on
513 + * DOM-ready. Keep them out of the flow until that move so they don't
514 + * flash above the dashboard header and shove the page down.
515 + * Once relocated they're no longer a direct child here, so this rule
516 + * stops matching and they fade in with the wrap. We mirror core's
517 + * relocation set exactly (.notice/.updated/.error, minus .inline and
518 + * .below-h2); .update-nag is left alone since core never moves it.
519 + */
520 + #wpbody-content > .notice:not(.inline):not(.below-h2),
521 + #wpbody-content > .updated:not(.inline):not(.below-h2),
522 + #wpbody-content > .error:not(.inline):not(.below-h2) { display: none !important; }
523 + </style>
484 524 <?php
485 525 }
486 526
527 + public function suppress_notices_on_wizard_page() {
528 + if ( ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
529 + return;
530 + }
531 +
532 + $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
533 +
534 + // Setup wizard: strip every notice for a fully focused, distraction-free screen.
535 + if ( strpos( $page, '-setup-wizard' ) !== false ) {
536 + remove_all_actions( 'admin_notices' );
537 + remove_all_actions( 'all_admin_notices' );
538 + return;
539 + }
540 +
541 + // Other Search Atlas plugin pages: strip third-party notices but keep our own.
542 + if ( strpos( $page, self::$page_slug ) === 0 ) {
543 + $this->remove_third_party_admin_notices();
544 + }
545 + }
546 +
547 + /**
548 + * Remove admin-notice callbacks that don't belong to this plugin.
549 + *
550 + * Third-party plugins (e.g. Rank Math, Yoast) register their banners on the
551 + * global admin_notices / all_admin_notices actions, which WordPress fires on
552 + * every admin screen — so their notices leak onto our settings pages. We walk
553 + * the registered callbacks and drop any whose code lives outside this plugin's
554 + * directory, leaving every one of our own notices intact.
555 + */
556 + private function remove_third_party_admin_notices() {
557 + global $wp_filter;
558 +
559 + foreach ( array( 'admin_notices', 'all_admin_notices' ) as $hook ) {
560 + if ( empty( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof WP_Hook ) ) {
561 + continue;
562 + }
563 +
564 + // Collect first, then remove — avoids mutating the callbacks array mid-walk.
565 + $to_remove = array();
566 + foreach ( $wp_filter[ $hook ]->callbacks as $priority => $callbacks ) {
567 + foreach ( $callbacks as $callback ) {
568 + if ( ! $this->is_own_admin_notice_callback( $callback['function'] ) ) {
569 + $to_remove[] = array( $callback['function'], $priority );
570 + }
571 + }
572 + }
573 +
574 + foreach ( $to_remove as $entry ) {
575 + remove_action( $hook, $entry[0], $entry[1] );
576 + }
577 + }
578 + }
579 +
580 + /**
581 + * Determine whether an admin-notice callback is defined by this plugin.
582 + *
583 + * Ownership is decided by where the callback's code physically lives, not by
584 + * class name — our notice owners use inconsistent prefixes (Metasync_Admin,
585 + * Google_Index_Admin, ConfigControllerMetaSync), and a white-label install can
586 + * rename the plugin folder. Resolving the declaring file and checking it sits
587 + * inside this plugin's directory is robust against all of that. If a callback
588 + * can't be introspected, we keep it rather than risk hiding a legitimate notice.
589 + *
590 + * @param string|array|Closure $function The registered callback.
591 + * @return bool True when the callback's code lives inside this plugin.
592 + */
593 + private function is_own_admin_notice_callback( $function ) {
594 + // Plugin root: this file lives in <plugin>/admin, so its parent is the root.
595 + $plugin_dir = wp_normalize_path( dirname( __DIR__ ) ) . '/';
596 +
597 + try {
598 + if ( is_array( $function ) && isset( $function[0], $function[1] ) ) {
599 + $class = is_object( $function[0] ) ? get_class( $function[0] ) : $function[0];
600 + $ref = new ReflectionMethod( $class, $function[1] );
601 + } elseif ( $function instanceof Closure ) {
602 + $ref = new ReflectionFunction( $function );
603 + } elseif ( is_string( $function ) && strpos( $function, '::' ) !== false ) {
604 + list( $class, $method ) = explode( '::', $function, 2 );
605 + $ref = new ReflectionMethod( $class, $method );
606 + } elseif ( is_string( $function ) ) {
607 + $ref = new ReflectionFunction( $function );
608 + } else {
609 + return false;
610 + }
611 + } catch ( \Throwable $e ) {
612 + // Unintrospectable callback — keep it; never hide a notice we can't classify.
613 + return true;
614 + }
615 +
616 + $file = $ref->getFileName();
617 + if ( ! $file ) {
618 + // Internal/built-in callback (no source file) — not ours.
619 + return false;
620 + }
621 +
622 + return strpos( wp_normalize_path( $file ), $plugin_dir ) === 0;
623 + }
624 +
487 625 #---------fixes issue : #95 ----------
488 626 #This function is to redirect in case client changes slug on fresh install
489 627 #It is called by the add_option hook
490 628
@@ -510,9 +648,9 @@
510 648
511 649 #Redirect url to the new slug
512 650 $redirect_url = admin_url('admin.php?page=' . $current_slug);
513 651
514 - wp_redirect($redirect_url);
652 + wp_safe_redirect($redirect_url);
515 653 exit;
516 654 }
517 655 }
518 656
@@ -564,13 +702,11 @@
564 702 * @since 1.0.0
565 703 */
566 704 public function maybe_redirect_to_wizard() {
567 705 // Only redirect if wizard should be shown
568 - if (get_option('metasync_show_wizard') && !isset($_GET['page'])) {
569 - delete_option('metasync_show_wizard');
570 -
706 + if (get_option('metasync_show_wizard') && !isset($_GET['page'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- isset() routing check for a redirect to a fixed admin URL
571 707 // Don't redirect during AJAX, cron, or bulk activation
572 - if (wp_doing_ajax() || wp_doing_cron() || isset($_GET['activate-multi'])) {
708 + if (wp_doing_ajax() || wp_doing_cron() || isset($_GET['activate-multi'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- isset() routing check for a redirect to a fixed admin URL
573 709 return;
574 710 }
575 711
576 712 // Only redirect if user has access to the plugin
@@ -577,16 +713,66 @@
577 713 if (!Metasync::current_user_has_plugin_access()) {
578 714 return;
579 715 }
580 716
717 + delete_option('metasync_show_wizard');
581 718 wp_safe_redirect(admin_url('admin.php?page=' . self::$page_slug . '-setup-wizard'));
582 719 exit;
583 720 }
584 721 }
585 722
723 + /**
724 + * Redirect the removed standalone Instant Indexing page to Indexation Control.
725 + *
726 + * The menu entry is gone, but bookmarks and external links can still target
727 + * the old URL. Resolve the current white-label slug rather than assuming
728 + * "searchatlas".
729 + *
730 + * Hooked on admin_menu (see its registration): the unregistered-page 403 in
731 + * wp-admin/includes/menu.php fires before admin_init, so any later hook
732 + * would be too late to redirect.
733 + */
734 + public function redirect_legacy_instant_indexing_page() {
735 + if (wp_doing_ajax() || wp_doing_cron() || !is_admin() || !isset($_GET['page'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
736 + return;
737 + }
738 +
739 + $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
740 + $legacy_page = self::$page_slug . '-instant-index';
741 + if ($page !== $legacy_page || !Metasync::current_user_has_plugin_access()) {
742 + return;
743 + }
744 +
745 + // Indexation Control is registered only when access control keeps it
746 + // visible. Redirecting to it blindly would dead-end on an
747 + // unregistered slug (wp_die "Invalid plugin page") for users whose
748 + // white-label config hides that page — the exact configs where the
749 + // removed standalone page was the feature's only UI. The bare plugin
750 + // slug is the floor: its top-level callback resolves to the first
751 + // reachable page, so it is always a safe target.
752 + if (Metasync_Access_Control::user_can_access('hide_indexation_control')) {
753 + $target = admin_url('admin.php?page=' . self::$page_slug . '-seo-controls');
754 + $query = [];
755 + foreach (['tab', 'subtab'] as $key) {
756 + if (isset($_GET[$key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
757 + $query[$key] = sanitize_key(wp_unslash($_GET[$key])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
758 + }
759 + }
760 + if (!empty($query)) {
761 + $target = add_query_arg($query, $target);
762 + }
763 + } else {
764 + $target = admin_url('admin.php?page=' . self::$page_slug);
765 + }
766 +
767 + wp_safe_redirect($target);
768 + exit;
769 + }
770 +
586 771 public function metasync_display_error_log() {
587 772 Metasync_Debug_Manager::instance()->metasync_display_error_log($this);
588 773 }
774 +
589 775 public function metasync_update_wp_config() {
590 776 Metasync_Debug_Manager::instance()->metasync_update_wp_config();
591 777 }
592 778
@@ -629,9 +815,9 @@
629 815 ]);
630 816 $_COOKIE['metasync_previous_slug'] = $new_slug;
631 817 // Redirect to the new slug
632 818 $redirect_url = admin_url('admin.php?page=' . $old_slug);
633 - wp_redirect($redirect_url);
819 + wp_safe_redirect($redirect_url);
634 820 exit;
635 821 }else{
636 822 self::$page_slug = Metasync::get_option('general')['white_label_plugin_menu_slug']=="" ? "searchatlas":Metasync::get_option('general')['white_label_plugin_menu_slug'];
637 823 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
@@ -637,9 +823,9 @@
637 823 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
638 824
639 825 #add redirection for when the old slug is not defined
640 826 #this fixes the redirect issue #
641 - wp_redirect($redirect_url);
827 + wp_safe_redirect($redirect_url);
642 828 exit;
643 829 }
644 830 }
645 831 public function metasync_view_detials_url( $plugin_meta, $plugin_file, $plugin_data ) {
@@ -650,9 +836,10 @@
650 836 if (strpos($meta, 'open-plugin-details-modal') !== false) {
651 837 $meta = sprintf(
652 838 '<a href="%s" class="thickbox open-plugin-details-modal" aria-label="%s" data-title="%s">%s</a>',
653 839 add_query_arg('TB_iframe', 'true', $plugin_uri),
654 - esc_attr(sprintf(esc_html__('More information about %s'), $plugin_data['Name'])),
840 + /* translators: %s: plugin name. */
841 + esc_attr(sprintf(esc_html__('More information about %s', 'metasync'), $plugin_data['Name'])),
655 842 esc_attr($plugin_data['Name']),
656 843 esc_html__('View details', 'metasync')
657 844 );
658 845 break; // Exit loop after replacing the link
@@ -700,53 +887,8 @@
700 887 return $all_plugins;
701 888 }
702 889
703 890 /**
704 - * Inject the whitelabel icon into the update_plugins transient so that
705 - * /wp-admin/update-core.php (Dashboard → Updates) shows the WL icon
706 - * instead of the SearchAtlas icon returned by the update API.
707 - *
708 - * @param object $transient The site transient object.
709 - * @return object
710 - */
711 - public function inject_whitelabel_icon_into_update_transient($transient) {
712 - if (empty($transient) || !is_object($transient)) {
713 - return $transient;
714 - }
715 -
716 - $general = Metasync::get_option('general');
717 - if (!is_array($general)) {
718 - return $transient;
719 - }
720 -
721 - $icon_url = $general['white_label_plugin_menu_icon'] ?? '';
722 - if (empty($icon_url)) {
723 - return $transient;
724 - }
725 -
726 - $this_plugin = plugin_basename(dirname(__DIR__) . '/metasync.php');
727 -
728 - // Inject into pending updates list
729 - if (!empty($transient->response) && isset($transient->response[$this_plugin])) {
730 - $transient->response[$this_plugin]->icons = [
731 - '1x' => $icon_url,
732 - '2x' => $icon_url,
733 - ];
734 - }
735 -
736 - // Also inject into the "no update needed" list so the icon appears
737 - // on the updates screen even when the plugin is up-to-date
738 - if (!empty($transient->no_update) && isset($transient->no_update[$this_plugin])) {
739 - $transient->no_update[$this_plugin]->icons = [
740 - '1x' => $icon_url,
741 - '2x' => $icon_url,
742 - ];
743 - }
744 -
745 - return $transient;
746 - }
747 -
748 - /**
749 891 * Register the stylesheets for the admin area.
750 892 *
751 893 * @since 1.0.0
752 894 */
@@ -751,9 +893,9 @@
751 893 * @since 1.0.0
752 894 */
753 895 public function enqueue_styles()
754 896 {
755 - $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
897 + $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
756 898
757 899 if (strpos($current_page, self::$page_slug) === 0) {
758 900 wp_enqueue_style(
759 901 $this->plugin_name,
@@ -781,10 +923,11 @@
781 923 'all'
782 924 );
783 925 }
784 926
785 - // Enqueue wizard CSS if on wizard page
786 - if (isset($_GET['page']) && strpos($_GET['page'], '-setup-wizard') !== false) {
927 + // Enqueue wizard CSS on all MetaSync pages because the reusable consent
928 + // card is also displayed inside General Settings.
929 + if (strpos($current_page, self::$page_slug) === 0 || (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-setup-wizard') !== false)) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
787 930 wp_enqueue_style(
788 931 $this->plugin_name . '-setup-wizard',
789 932 plugin_dir_url(__FILE__) . 'css/metasync-setup-wizard.css',
790 933 array($this->plugin_name . '-dashboard'),
@@ -793,9 +936,9 @@
793 936 );
794 937 }
795 938
796 939 // Enqueue SEO Health CSS if on the SEO Health page
797 - if (isset($_GET['page']) && strpos($_GET['page'], '-seo-health') !== false) {
940 + if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-seo-health') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
798 941 wp_enqueue_style(
799 942 $this->plugin_name . '-seo-health',
800 943 plugin_dir_url(__FILE__) . 'css/metasync-seo-health.css',
801 944 array($this->plugin_name . '-dashboard'),
@@ -812,9 +955,9 @@
812 955 */
813 956 public function enqueue_scripts()
814 957 {
815 958 // --- Phase 5 (#887): Extracted inline JS files ---
816 - $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
959 + $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick scripts
817 960 $plugin_root_url = plugin_dir_url(dirname(__FILE__));
818 961 $is_metasync_page = ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0);
819 962
820 963 if ($is_metasync_page) {
@@ -844,10 +987,49 @@
844 987 array('jquery'),
845 988 $this->version,
846 989 true
847 990 );
991 +
992 + // Keeps core's admin-menu pinning in step with pages whose height
993 + // changes after load. Depends on 'common' so core has bound its
994 + // own handlers before this runs.
995 + wp_enqueue_script(
996 + $this->plugin_name . '-admin-menu-height',
997 + plugin_dir_url(__FILE__) . 'js/metasync-admin-menu-height.js',
998 + array('jquery', 'common'),
999 + $this->version,
1000 + true
1001 + );
848 1002 }
849 1003
1004 + $per_page_screens = [
1005 + self::$page_slug . '-redirections',
1006 + self::$page_slug . '-404-monitor',
1007 + self::$page_slug . '-sync-log',
1008 + self::$page_slug . '-media-optimization',
1009 + ];
1010 +
1011 + if (in_array($current_page, $per_page_screens, true)) {
1012 + // Results-per-page selectors use this external handler instead of
1013 + // an inline event attribute so changing a page size always reloads
1014 + // the current admin page.
1015 + wp_enqueue_script(
1016 + $this->plugin_name . '-per-page',
1017 + plugin_dir_url(__FILE__) . 'js/metasync-per-page.js',
1018 + array(),
1019 + $this->version,
1020 + true
1021 + );
1022 + // Selector navigation is a GET, so the per-page nonce rides along
1023 + // as a query parameter and gates the user_meta persist in
1024 + // Metasync_Per_Page_Helper::resolve().
1025 + wp_localize_script(
1026 + $this->plugin_name . '-per-page',
1027 + 'metasyncPerPage',
1028 + array('nonces' => Metasync_Per_Page_Helper::page_nonces())
1029 + );
1030 + }
1031 +
850 1032 // Dashboard iframe height (only on dashboard page)
851 1033 if ($current_page === self::$page_slug . '-dashboard' || $current_page === self::$page_slug) {
852 1034 wp_enqueue_script(
853 1035 $this->plugin_name . '-iframe',
@@ -896,12 +1078,12 @@
896 1078 true
897 1079 );
898 1080 // Pass the active tab from server (handles POST redirect)
899 1081 $sitemap_active_tab = 'general';
900 - if (isset($_GET['tab']) && in_array($_GET['tab'], ['general', 'news', 'video'], true)) {
901 - $sitemap_active_tab = sanitize_text_field(wp_unslash($_GET['tab']));
902 - } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) {
903 - $sitemap_active_tab = sanitize_text_field(wp_unslash($_POST['redirect_tab']));
1082 + if (isset($_GET['tab']) && in_array($_GET['tab'], ['general', 'news', 'video'], true)) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed tab selection for the UI
1083 + $sitemap_active_tab = sanitize_text_field(wp_unslash($_GET['tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed tab selection for the UI
1084 + } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only, allow-listed tab selection for the UI
1085 + $sitemap_active_tab = sanitize_text_field(wp_unslash($_POST['redirect_tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only, allow-listed tab selection for the UI
904 1086 }
905 1087 wp_localize_script($this->plugin_name . '-sitemap-tabs', 'metasyncSitemapTabs', [
906 1088 'activeTab' => $sitemap_active_tab,
907 1089 ]);
@@ -1032,9 +1214,8 @@
1032 1214 true
1033 1215 );
1034 1216 wp_localize_script($this->plugin_name . '-connect', 'metasyncConnectData', array(
1035 1217 'optionKey' => self::option_key,
1036 - 'storedPassword' => isset($whitelabel_data['settings_password']) ? $whitelabel_data['settings_password'] : '',
1037 1218 'adminPostUrl' => admin_url('admin-post.php'),
1038 1219 'exportNonce' => wp_create_nonce('metasync_export_whitelabel'),
1039 1220 'ajaxUrl' => admin_url('admin-ajax.php'),
1040 1221 'logoutNonceField' => wp_nonce_field('whitelabel_logout_nonce', 'whitelabel_logout_nonce', true, false),
@@ -1122,8 +1303,11 @@
1122 1303 array('jquery'),
1123 1304 $this->version,
1124 1305 true
1125 1306 );
1307 + wp_localize_script($this->plugin_name . '-bing-console', 'metasyncBingConsoleData', array(
1308 + 'nonce' => wp_create_nonce('metasync_nonce'),
1309 + ));
1126 1310 }
1127 1311
1128 1312 // Add redirection form
1129 1313 if ($current_page === self::$page_slug . '-redirections') {
@@ -1227,8 +1411,10 @@
1227 1411 'nonce' => wp_create_nonce('metasync_nonce'),
1228 1412 'sa_connect_nonce' => $sa_connect_nonce,
1229 1413 'reset_auth_nonce' => wp_create_nonce('metasync_reset_auth_nonce'),
1230 1414 'burst_ping_nonce' => wp_create_nonce('metasync_burst_ping'),
1415 + 'clear_otto_cache_nonce' => wp_create_nonce('metasync_clear_otto_cache'),
1416 + 'lglogin_nonce' => wp_create_nonce('metasync_lglogin'),
1231 1417 'heartbeat_state' => $heartbeat_state,
1232 1418 'dashboard_domain' => self::get_effective_dashboard_domain(),
1233 1419 'support_email' => Metasync::SUPPORT_EMAIL,
1234 1420 'documentation_domain' => Metasync::DOCUMENTATION_DOMAIN,
@@ -1292,9 +1478,9 @@
1292 1478 add_action('admin_notices', array($this, 'display_page_builder_notice'));
1293 1479 // Display update warning banner if plugin update is available
1294 1480 add_action('admin_notices', array($this, 'display_update_warning_banner'));
1295 1481 // Enqueue wizard assets if on wizard page
1296 - if (isset($_GET['page']) && strpos($_GET['page'], '-setup-wizard') !== false) {
1482 + if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-setup-wizard') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick scripts
1297 1483 wp_enqueue_script(
1298 1484 $this->plugin_name . '-setup-wizard',
1299 1485 plugin_dir_url(__FILE__) . 'js/metasync-setup-wizard.js',
1300 1486 array('jquery'),
@@ -1318,14 +1504,44 @@
1318 1504 }
1319 1505
1320 1506 /**
1321 1507 * Settings of HeartBeat API for admin area.
1322 - * Set time interval of send request.
1508 + *
1509 + * The only thing the plugin rides the beat for is a periodic admin
1510 + * telemetry ping (the `heartbeat-send` handler in admin/js/metasync-admin.js),
1511 + * so it slows the beat down rather than speeding it up. Two contexts are
1512 + * left on whatever core decided, because core leans on the beat there:
1513 + *
1514 + * - The front end, where the beat drives wp-auth-check — the "your session
1515 + * has expired, log in again" prompt.
1516 + * - The editor screens, where each tick renews the post lock. A lock only
1517 + * lives for wp_check_post_lock_window() seconds (150 by default), so any
1518 + * interval longer than that drops the lock between beats and lets a
1519 + * second editor silently take over a post someone still has open.
1520 + *
1521 + * Core clamps this to 1-3600 seconds in wp-includes/js/heartbeat.js, so an
1522 + * over-long interval is not corrected on our behalf.
1523 + *
1524 + * @param array $settings Heartbeat settings array.
1525 + * @return array Heartbeat settings array.
1323 1526 */
1324 1527 function metasync_heartbeat_settings($settings)
1325 1528 {
1326 - global $heartbeat_frequency;
1327 - $settings['interval'] = 300;
1529 + if (!is_admin()) {
1530 + return $settings;
1531 + }
1532 +
1533 + # $pagenow is set in wp-includes/vars.php, long before this filter runs;
1534 + # core's own wp_heartbeat_set_suspension() reads it exactly this way.
1535 + global $pagenow;
1536 + $lock_dependent_screens = array('post.php', 'post-new.php', 'customize.php');
1537 + if (isset($pagenow) && in_array($pagenow, $lock_dependent_screens, true)) {
1538 + return $settings;
1539 + }
1540 +
1541 + # 60s matches heartbeat.js's own default and stays well inside the
1542 + # 150s post-lock window, so nothing core does can fall through a gap.
1543 + $settings['interval'] = 60;
1328 1544 return $settings;
1329 1545 }
1330 1546
1331 1547 /**
@@ -1463,13 +1679,8 @@
1463 1679 {
1464 1680 Metasync_Connect_Manager::instance()->test_searchatlas_ajax_endpoint();
1465 1681 }
1466 1682
1467 - public function simple_ajax_test()
1468 - {
1469 - Metasync_Connect_Manager::instance()->simple_ajax_test();
1470 - }
1471 -
1472 1683 /**
1473 1684 * Create Admin Dashboard Iframe Page
1474 1685 * Embeds the Search Atlas dashboard directly in WordPress admin
1475 1686 */
@@ -1494,8 +1705,47 @@
1494 1705 // Get cache count
1495 1706 $cache_count = Metasync_Otto_Transient_Cache::get_cache_count();
1496 1707
1497 1708 ?>
1709 + <!-- OTTO Cache TTL Setting -->
1710 + <div style="margin-bottom: 30px; padding-top: 20px;">
1711 + <h4 style="margin-top: 0; color: var(--dashboard-text-primary);"><?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> Cache TTL</h4>
1712 + <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">
1713 + Configure how long <?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> API suggestions are cached before a fresh API call. The stale fallback expires after 12 hours.
1714 + </p>
1715 +
1716 + <div style="background: rgba(255,255,255,0.02); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
1717 + <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
1718 + <label for="metasync-otto-cache-ttl" style="color: var(--dashboard-text-primary); font-weight: 500;">
1719 + Cache TTL (minutes):
1720 + </label>
1721 + <?php Metasync::render_tooltip_icon('otto_cache_ttl', 'How many minutes OTTO reuses its last SEO suggestions before fetching fresh ones. 30 is fine for most sites.'); ?>
1722 + <input type="number"
1723 + id="metasync-otto-cache-ttl"
1724 + value="<?php echo esc_attr($this->get_otto_cache_ttl_minutes()); ?>"
1725 + min="30"
1726 + max="1440"
1727 + step="1"
1728 + 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);" />
1729 + <span style="color: var(--dashboard-text-secondary); font-size: 13px;">min 30 · max 1440</span>
1730 + </div>
1731 +
1732 + <div style="display: flex; align-items: center; gap: 12px;">
1733 + <button type="button"
1734 + id="metasync-otto-ttl-save-btn"
1735 + class="metasync-btn-primary"
1736 + 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;"
1737 + onmouseover="this.style.transform='translateY(-1px)';"
1738 + onmouseout="this.style.transform='translateY(0)';">
1739 + Save TTL
1740 + </button>
1741 + <span id="metasync-otto-ttl-save-msg" style="display: none; font-size: 13px;"></span>
1742 + </div>
1743 +
1744 + <input type="hidden" id="metasync-otto-ttl-nonce" value="<?php echo esc_attr(wp_create_nonce('metasync_otto_cache_ttl_nonce')); ?>" />
1745 + </div>
1746 + </div>
1747 +
1498 1748 <!-- Cache Plugin Management -->
1499 1749 <div style="margin-bottom: 30px;">
1500 1750 <h4 style="margin-top: 0; color: var(--dashboard-text-primary);">Clear All Cache Plugins</h4>
1501 1751 <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">Clear all cache plugins to ensure changes are visible immediately.</p>
@@ -1517,9 +1767,9 @@
1517 1767 } else {
1518 1768 echo '<p style="color: var(--dashboard-text-secondary);">ℹ️ No cache plugins detected.</p>';
1519 1769 }
1520 1770 } catch (Exception $e) {
1521 - error_log('MetaSync Cache Status Error: ' . $e->getMessage());
1771 + error_log('MetaSync Cache Status Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1522 1772 echo '<p style="color: var(--dashboard-error);">⚠️ An error occurred while retrieving cache plugin status.</p>';
1523 1773 }
1524 1774 } else {
1525 1775 echo '<p style="color: var(--dashboard-error);">⚠️ Cache Purge class not loaded.</p>';
@@ -1529,9 +1779,9 @@
1529 1779 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="margin-top: 15px;">
1530 1780 <input type="hidden" name="action" value="metasync_clear_all_cache_plugins" />
1531 1781 <?php wp_nonce_field('metasync_clear_cache_nonce', 'clear_cache_nonce'); ?>
1532 1782 <button type="submit" class="metasync-btn-primary" style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 10px 20px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); display: inline-block; width: auto; min-width: 240px; max-width: fit-content;" onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 8px rgba(0, 0, 0, 0.15)';" onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 2px 4px rgba(0, 0, 0, 0.1)';">
1533 - <span class="dashicons dashicons-controls-repeat" style="margin-top:3px;font-size:15px;width:15px;height:15px;"></span> Clear All Cache Plugins
1783 + <span class="dashicons dashicons-controls-repeat"></span> Clear All Cache Plugins
1534 1784 </button>
1535 1785 <p class="description" style="margin-top: 10px; color: var(--dashboard-text-secondary);">This will clear cache from WP Rocket, LiteSpeed, W3 Total Cache, and all other detected cache plugins.</p>
1536 1786 </form>
1537 1787
@@ -1536,12 +1786,12 @@
1536 1786 </form>
1537 1787
1538 1788 <?php
1539 1789 // Display success/error messages
1540 - if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') {
1541 - $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0;
1542 - $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0;
1543 - $plugins = isset($_GET['plugins']) ? sanitize_text_field(wp_unslash($_GET['plugins'])) : '';
1790 + if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1791 + $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1792 + $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1793 + $plugins = isset($_GET['plugins']) ? sanitize_text_field(wp_unslash($_GET['plugins'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1544 1794
1545 1795 if ($cleared > 0) {
1546 1796 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1547 1797 echo '✅ <strong>Success!</strong> Cleared cache for ' . intval( $cleared ) . ' plugin(s)';
@@ -1561,10 +1811,10 @@
1561 1811 echo '</p></div>';
1562 1812 }
1563 1813 }
1564 1814
1565 - if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') {
1566 - $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : '';
1815 + if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1816 + $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1567 1817 if (empty($message)) {
1568 1818 $message = 'An unknown error occurred while clearing cache. Please check error logs for details.';
1569 1819 }
1570 1820 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
@@ -1613,8 +1863,9 @@
1613 1863 <?php checked(true, !empty($hosting_settings['wpengine_enabled'])); ?>
1614 1864 <?php echo !$wpe_detected ? 'disabled' : ''; ?>
1615 1865 style="width: 16px; height: 16px; cursor: <?php echo $wpe_detected ? 'pointer' : 'not-allowed'; ?>;" />
1616 1866 <span style="color: var(--dashboard-text-primary); font-weight: 500;">WP Engine</span>
1867 + <?php Metasync::render_tooltip_icon('hosting_cache_wpengine', 'If hosted on WP Engine, turn on so OTTO clears the host\'s built-in cache when it updates a page.'); ?>
1617 1868 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges Varnish + Memcached</span>
1618 1869 </label>
1619 1870
1620 1871 <label style="display: flex; align-items: center; gap: 10px; margin-bottom: 16px; cursor: pointer;">
@@ -1623,8 +1874,9 @@
1623 1874 <?php checked(true, !empty($hosting_settings['kinsta_enabled'])); ?>
1624 1875 <?php echo !$kinsta_detected ? 'disabled' : ''; ?>
1625 1876 style="width: 16px; height: 16px; cursor: <?php echo $kinsta_detected ? 'pointer' : 'not-allowed'; ?>;" />
1626 1877 <span style="color: var(--dashboard-text-primary); font-weight: 500;">Kinsta</span>
1878 + <?php Metasync::render_tooltip_icon('hosting_cache_kinsta', 'If hosted on Kinsta, turn on so OTTO clears Kinsta\'s server cache automatically.'); ?>
1627 1879 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges full-page cache (kinsta_cache_purge_full)</span>
1628 1880 </label>
1629 1881
1630 1882 <div style="display: flex; align-items: center; gap: 12px;">
@@ -1638,9 +1890,9 @@
1638 1890 </button>
1639 1891 <span id="metasync-hc-save-msg" style="display: none; font-size: 13px;"></span>
1640 1892 </div>
1641 1893
1642 - <input type="hidden" id="metasync-hc-nonce" value="<?php echo wp_create_nonce('metasync_hosting_cache_nonce'); ?>" />
1894 + <input type="hidden" id="metasync-hc-nonce" value="<?php echo esc_attr(wp_create_nonce('metasync_hosting_cache_nonce')); ?>" />
1643 1895 </div>
1644 1896
1645 1897 <!-- Purge button -->
1646 1898 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
@@ -1659,12 +1911,13 @@
1659 1911 </form>
1660 1912
1661 1913 <?php
1662 1914 // Hosting cache result messages
1663 - if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') {
1664 - $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode($_GET['hc_cleared'])) : '';
1665 - $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode($_GET['hc_failed'])) : '';
1666 - $hc_not_detected = isset($_GET['hc_not_detected']) ? sanitize_text_field(urldecode($_GET['hc_not_detected'])) : '';
1915 + if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1916 + // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each value is unslashed and sanitized on the following assignments.
1917 + $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_cleared']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1918 + $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_failed']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1919 + $hc_not_detected = isset($_GET['hc_not_detected']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_not_detected']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1667 1920
1668 1921 if ($hc_cleared) {
1669 1922 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1670 1923 echo '✅ <strong>Success!</strong> Purged hosting cache on: ' . esc_html($hc_cleared);
@@ -1679,8 +1932,9 @@
1679 1932 echo '<div class="notice notice-info inline" style="margin-top: 10px;"><p>';
1680 1933 echo 'ℹ️ No enabled hosting providers were detected on this server (' . esc_html($hc_not_detected) . ').';
1681 1934 echo '</p></div>';
1682 1935 }
1936 + // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1683 1937 }
1684 1938 ?>
1685 1939
1686 1940 <script>
@@ -1715,8 +1969,46 @@
1715 1969 setTimeout(function() { $msg.fadeOut(); }, 4000);
1716 1970 }
1717 1971 });
1718 1972 });
1973 +
1974 + // OTTO Cache TTL save
1975 + $('#metasync-otto-ttl-save-btn').on('click', function() {
1976 + var $btn = $(this);
1977 + var $msg = $('#metasync-otto-ttl-save-msg');
1978 + var ttl = parseInt($('#metasync-otto-cache-ttl').val(), 10);
1979 +
1980 + if (isNaN(ttl) || ttl < 30 || ttl > 1440) {
1981 + $msg.text('❌ TTL must be between 30 and 1440 minutes.').css('color', '#ef4444').show();
1982 + return;
1983 + }
1984 +
1985 + $btn.prop('disabled', true).text('Saving…');
1986 +
1987 + $.ajax({
1988 + url: ajaxurl,
1989 + type: 'POST',
1990 + data: {
1991 + action: 'metasync_save_otto_cache_ttl',
1992 + otto_cache_ttl_nonce: $('#metasync-otto-ttl-nonce').val(),
1993 + otto_cache_ttl: ttl,
1994 + },
1995 + success: function(response) {
1996 + if (response.success) {
1997 + $msg.text('✅ Saved').css('color', '#22c55e').show();
1998 + } else {
1999 + $msg.text('❌ ' + (response.data && response.data.message ? response.data.message : 'Save failed')).css('color', '#ef4444').show();
2000 + }
2001 + },
2002 + error: function() {
2003 + $msg.text('❌ Request failed').css('color', '#ef4444').show();
2004 + },
2005 + complete: function() {
2006 + $btn.prop('disabled', false).text('Save TTL');
2007 + setTimeout(function() { $msg.fadeOut(); }, 4000);
2008 + }
2009 + });
2010 + });
1719 2011 });
1720 2012 </script>
1721 2013 </div>
1722 2014
@@ -1734,9 +2026,9 @@
1734 2026 id="metasync-targeted-object-cache"
1735 2027 <?php checked('1', $targeted_cache_enabled); ?>
1736 2028 style="width: 16px; height: 16px; cursor: pointer; margin-top: 2px; flex-shrink: 0;" />
1737 2029 <span>
1738 - <span style="color: var(--dashboard-text-primary); font-weight: 500; display: block; margin-bottom: 4px;">Targeted Object Cache Purge</span>
2030 + <span style="color: var(--dashboard-text-primary); font-weight: 500; display: block; margin-bottom: 4px;">Targeted Object Cache Purge<?php Metasync::render_tooltip_icon('targeted_object_cache_purge', 'Leave this on. Refreshes only the pages OTTO changed instead of wiping your whole site\'s memory cache — safer/faster on large sites.'); ?></span>
1739 2031 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">
1740 2032 When enabled, only the updated posts are evicted from the object cache (recommended for large sites).
1741 2033 When disabled, a full <code>wp_cache_flush()</code> is used instead.
1742 2034 </span>
@@ -1807,11 +2099,11 @@
1807 2099 </div>
1808 2100
1809 2101 <?php
1810 2102 // Display success/error messages
1811 - if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') {
1812 - $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0;
1813 - $url = isset($_GET['url']) ? urldecode(sanitize_text_field(wp_unslash($_GET['url']))) : '';
2103 + if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2104 + $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2105 + $url = isset($_GET['url']) ? urldecode(sanitize_text_field(wp_unslash($_GET['url']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
1814 2106
1815 2107 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1816 2108 if (!empty($url)) {
1817 2109 echo '✅ <strong>Success!</strong> Cleared cache for URL: <code>' . esc_html($url) . '</code> (' . intval( $cleared_count ) . ' entries)';
@@ -1820,10 +2112,10 @@
1820 2112 }
1821 2113 echo '</p></div>';
1822 2114 }
1823 2115
1824 - if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') {
1825 - $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : 'An unknown error occurred.';
2116 + if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2117 + $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : 'An unknown error occurred.'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
1826 2118 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
1827 2119 echo '❌ <strong>Error:</strong> ' . esc_html($message);
1828 2120 echo '</p></div>';
1829 2121 }
@@ -1862,9 +2154,9 @@
1862 2154 <td>
1863 2155 <input type="url"
1864 2156 id="otto_cache_url"
1865 2157 name="otto_cache_url"
1866 - value="<?php echo isset($_GET['url']) ? esc_attr(urldecode(sanitize_text_field(wp_unslash($_GET['url'])))) : ''; ?>"
2158 + value="<?php echo isset($_GET['url']) ? esc_attr(urldecode(sanitize_text_field(wp_unslash($_GET['url'])))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce ?>"
1867 2159 class="regular-text"
1868 2160 placeholder="https://example.com/page/"
1869 2161 required />
1870 2162 <p class="description" style="color: var(--dashboard-text-secondary);">Enter the full URL of the page whose cache you want to clear.</p>
@@ -1935,9 +2227,10 @@
1935 2227 /**
1936 2228 * AJAX handler for saving hosting cache settings
1937 2229 */
1938 2230 public function ajax_save_hosting_cache_settings() {
1939 - if (!isset($_POST['hosting_cache_nonce']) || !wp_verify_nonce($_POST['hosting_cache_nonce'], 'metasync_hosting_cache_nonce')) {
2231 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2232 + if (!isset($_POST['hosting_cache_nonce']) || !wp_verify_nonce(wp_unslash($_POST['hosting_cache_nonce']), 'metasync_hosting_cache_nonce')) {
1940 2233 wp_send_json_error(array('message' => 'Security check failed'));
1941 2234 return;
1942 2235 }
1943 2236
@@ -1958,9 +2251,10 @@
1958 2251 /**
1959 2252 * AJAX handler: save object cache behaviour settings
1960 2253 */
1961 2254 public function ajax_save_object_cache_settings() {
1962 - if (!isset($_POST['object_cache_nonce']) || !wp_verify_nonce($_POST['object_cache_nonce'], 'metasync_object_cache_nonce')) {
2255 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2256 + if (!isset($_POST['object_cache_nonce']) || !wp_verify_nonce(wp_unslash($_POST['object_cache_nonce']), 'metasync_object_cache_nonce')) {
1963 2257 wp_send_json_error(array('message' => 'Security check failed'));
1964 2258 return;
1965 2259 }
1966 2260
@@ -1974,12 +2268,52 @@
1974 2268 wp_send_json_success(array('message' => 'Object cache settings saved'));
1975 2269 }
1976 2270
1977 2271 /**
2272 + * Get OTTO Cache TTL value in minutes from execution settings.
2273 + *
2274 + * @return int
2275 + */
2276 + private function get_otto_cache_ttl_minutes() {
2277 + $execution_settings = get_option('metasync_execution_settings', array());
2278 + return isset($execution_settings['otto_cache_ttl']) ? absint($execution_settings['otto_cache_ttl']) : 30;
2279 + }
2280 +
2281 + /**
2282 + * AJAX handler for saving OTTO Cache TTL
2283 + */
2284 + public function ajax_save_otto_cache_ttl() {
2285 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2286 + if (!isset($_POST['otto_cache_ttl_nonce']) || !wp_verify_nonce(wp_unslash($_POST['otto_cache_ttl_nonce']), 'metasync_otto_cache_ttl_nonce')) {
2287 + wp_send_json_error(array('message' => 'Security check failed.'));
2288 + return;
2289 + }
2290 +
2291 + if (!current_user_can('manage_options')) {
2292 + wp_send_json_error(array('message' => 'Insufficient permissions.'));
2293 + return;
2294 + }
2295 +
2296 + $ttl = isset($_POST['otto_cache_ttl']) ? absint($_POST['otto_cache_ttl']) : 30;
2297 +
2298 + if ($ttl < 30 || $ttl > 1440) {
2299 + wp_send_json_error(array('message' => sprintf('%s Cache TTL must be between 30 and 1440 minutes.', Metasync::get_whitelabel_otto_name())));
2300 + return;
2301 + }
2302 +
2303 + $settings = get_option('metasync_execution_settings', array());
2304 + $settings['otto_cache_ttl'] = $ttl;
2305 + update_option('metasync_execution_settings', $settings);
2306 +
2307 + wp_send_json_success(array('message' => sprintf('%s Cache TTL saved.', Metasync::get_whitelabel_otto_name())));
2308 + }
2309 +
2310 + /**
1978 2311 * admin_post handler: purge WP Engine and Kinsta hosting-level caches
1979 2312 */
1980 2313 public function handle_purge_hosting_cache() {
1981 - if (!isset($_POST['hosting_cache_purge_nonce']) || !wp_verify_nonce($_POST['hosting_cache_purge_nonce'], 'metasync_hosting_cache_purge_nonce')) {
2314 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2315 + if (!isset($_POST['hosting_cache_purge_nonce']) || !wp_verify_nonce(wp_unslash($_POST['hosting_cache_purge_nonce']), 'metasync_hosting_cache_purge_nonce')) {
1982 2316 wp_die('Security check failed');
1983 2317 }
1984 2318
1985 2319 if (!Metasync::current_user_has_plugin_access()) {
@@ -1999,9 +2333,9 @@
1999 2333 WpeCommon::purge_varnish_cache();
2000 2334 WpeCommon::purge_memcached();
2001 2335 $cleared[] = 'WP Engine';
2002 2336 } catch (Exception $e) {
2003 - error_log('MetaSync: WP Engine hosting cache purge failed - ' . $e->getMessage());
2337 + error_log('MetaSync: WP Engine hosting cache purge failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2004 2338 $failed[] = 'WP Engine';
2005 2339 }
2006 2340 } else {
2007 2341 $not_detected[] = 'WP Engine';
@@ -2014,9 +2348,9 @@
2014 2348 try {
2015 2349 KinstaCache::get_instance()->kinsta_cache_purge_full();
2016 2350 $cleared[] = 'Kinsta';
2017 2351 } catch (Exception $e) {
2018 - error_log('MetaSync: Kinsta hosting cache purge failed - ' . $e->getMessage());
2352 + error_log('MetaSync: Kinsta hosting cache purge failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2019 2353 $failed[] = 'Kinsta';
2020 2354 }
2021 2355 } else {
2022 2356 $not_detected[] = 'Kinsta';
@@ -2411,9 +2745,9 @@
2411 2745 # Initialize MetaSync API request class and trigger synchronization
2412 2746 (new Metasync_Sync_Requests())->SyncCustomerParams();
2413 2747 } catch (Exception $e) {
2414 2748 # Log any API request errors for debugging
2415 - error_log('Metasync API Error: ' . $e->getMessage());
2749 + error_log('Metasync API Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2416 2750 }
2417 2751 }
2418 2752
2419 2753 /*
@@ -2428,9 +2762,9 @@
2428 2762 # Initialize MetaSync API request class and trigger synchronization
2429 2763 (new Metasync_Sync_Requests())->SyncCustomerParams();
2430 2764 } catch (Exception $e) {
2431 2765 # Log any API request errors for debugging
2432 - error_log('Metasync API Error: ' . $e->getMessage());
2766 + error_log('Metasync API Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2433 2767 }
2434 2768 }
2435 2769
2436 2770 /**
@@ -2463,8 +2797,15 @@
2463 2797 // Handle form submissions
2464 2798 if (isset($_POST['metasync_media_optimization_nonce'])) {
2465 2799 check_admin_referer('metasync_save_media_optimization', 'metasync_media_optimization_nonce');
2466 2800
2801 + // Settings shape the whole site's conversion pipeline — restrict
2802 + // the save (and the reset) to site admins, not just anyone who can
2803 + // open the page via plugin_access_roles.
2804 + if (!current_user_can('manage_options')) {
2805 + wp_die(esc_html__('Sorry, you are not allowed to manage media optimization settings.', 'metasync'));
2806 + }
2807 +
2467 2808 // Handle reset to defaults
2468 2809 if (!empty($_POST['metasync_media_reset'])) {
2469 2810 $defaults = Metasync_Media_Settings::get_defaults();
2470 2811 Metasync_Media_Settings::save_settings($defaults);
@@ -2469,8 +2810,9 @@
2469 2810 $defaults = Metasync_Media_Settings::get_defaults();
2470 2811 Metasync_Media_Settings::save_settings($defaults);
2471 2812 $save_success = true;
2472 2813 } elseif (isset($_POST['metasync_media'])) {
2814 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Media_Settings::save_settings().
2473 2815 $input = wp_unslash($_POST['metasync_media']);
2474 2816 Metasync_Media_Settings::save_settings($input);
2475 2817 $save_success = true;
2476 2818 }
@@ -2502,8 +2844,15 @@
2502 2844 * Code Minification page callback
2503 2845 */
2504 2846 public function create_admin_code_minification_page()
2505 2847 {
2848 + // Settings shape the whole site's asset pipeline — restrict
2849 + // the save (and the reset) to site admins, not just anyone who can
2850 + // open the page via plugin_access_roles.
2851 + if (!empty($_POST) && !current_user_can('manage_options')) {
2852 + wp_die(esc_html__('Sorry, you are not allowed to manage code minification settings.', 'metasync'));
2853 + }
2854 +
2506 2855 $this->render_layout_open('Code Minification', 'code_minification', 'Minify CSS, JavaScript, and HTML to improve performance.');
2507 2856 // Load settings and compatibility classes
2508 2857 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-settings.php';
2509 2858 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-cache.php';
@@ -2520,8 +2869,9 @@
2520 2869 $defaults = Metasync_Minification_Settings::get_defaults();
2521 2870 Metasync_Minification_Settings::save_settings($defaults);
2522 2871 $save_success = true;
2523 2872 } elseif (isset($_POST['metasync_code_min'])) {
2873 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Minification_Settings::save_settings().
2524 2874 $input = (array) wp_unslash($_POST['metasync_code_min']);
2525 2875 Metasync_Minification_Settings::save_settings($input);
2526 2876 $save_success = true;
2527 2877 }
@@ -2554,8 +2904,15 @@
2554 2904 if (!$attachment_id) {
2555 2905 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2556 2906 }
2557 2907
2908 + // Ownership: under the replace strategy this permanently deletes the
2909 + // attachment's original file, so upload_files alone is not enough —
2910 + // the caller must be allowed to edit this specific attachment.
2911 + if (!current_user_can('edit_post', $attachment_id)) {
2912 + wp_send_json_error(__('Permission denied.', 'metasync'));
2913 + }
2914 +
2558 2915 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-settings.php';
2559 2916 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-image-converter.php';
2560 2917 $settings = Metasync_Media_Settings::get_settings();
2561 2918
@@ -2569,8 +2926,9 @@
2569 2926 $max_bytes = 10 * 1024 * 1024;
2570 2927 if (filesize($file) > $max_bytes) {
2571 2928 $size_mb = round(filesize($file) / 1024 / 1024, 1);
2572 2929 wp_send_json_error(sprintf(
2930 + /* translators: %s: file size in megabytes. */
2573 2931 __('Optimization skipped: file size (%s MB) exceeds the 10 MB safety limit to prevent memory issues.', 'metasync'),
2574 2932 $size_mb
2575 2933 ));
2576 2934 }
@@ -2584,8 +2942,9 @@
2584 2942 if ($success) {
2585 2943 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
2586 2944 wp_send_json_success([
2587 2945 'status_html' => Metasync_Media_Library_List_Table::render_status_html($attachment_id),
2946 + 'can_revert' => Metasync_Image_Converter::can_revert($attachment_id),
2588 2947 ]);
2589 2948 }
2590 2949
2591 2950 wp_send_json_error(__('Optimization failed: conversion could not be completed.', 'metasync'));
@@ -2606,8 +2965,20 @@
2606 2965 if (!$attachment_id) {
2607 2966 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2608 2967 }
2609 2968
2969 + // Ownership: reverting deletes the attachment's converted files, so
2970 + // restrict it to users who may edit this specific attachment.
2971 + if (!current_user_can('edit_post', $attachment_id)) {
2972 + wp_send_json_error(__('Permission denied.', 'metasync'));
2973 + }
2974 +
2975 + // Replace-strategy conversions have no original to restore — reverting
2976 + // would delete the attachment's only file. Refuse up front, like bulk revert does.
2977 + if (!Metasync_Image_Converter::can_revert($attachment_id)) {
2978 + wp_send_json_error(__('This image cannot be reverted. The original file no longer exists (replace strategy).', 'metasync'));
2979 + }
2980 +
2610 2981 $success = Metasync_Image_Converter::revert_attachment($attachment_id);
2611 2982
2612 2983 if ($success) {
2613 2984 wp_send_json_success();
@@ -2659,8 +3030,15 @@
2659 3030 public function ajax_batch_progress()
2660 3031 {
2661 3032 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
2662 3033
3034 + // Read-only progress/stats polling — the same capability that can
3035 + // open the media optimization page is enough, but a nonce alone
3036 + // never was.
3037 + if (!current_user_can('upload_files')) {
3038 + wp_send_json_error(__('Permission denied.', 'metasync'));
3039 + }
3040 +
2663 3041 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-batch-optimizer.php';
2664 3042 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
2665 3043
2666 3044 $progress = Metasync_Media_Batch_Optimizer::get_progress();
@@ -2691,10 +3069,19 @@
2691 3069 $settings = Metasync_Media_Settings::get_settings();
2692 3070
2693 3071 $success = 0;
2694 3072 $failed = 0;
3073 + $denied = 0;
2695 3074
2696 3075 foreach ($ids as $id) {
3076 + // Per-attachment ownership: replace mode deletes originals, so
3077 + // upload_files alone must not allow converting images the caller
3078 + // does not own/cannot edit.
3079 + if (!current_user_can('edit_post', $id)) {
3080 + $denied++;
3081 + continue;
3082 + }
3083 +
2697 3084 if (Metasync_Image_Converter::convert_attachment($id, $settings)) {
2698 3085 $success++;
2699 3086 } else {
2700 3087 $failed++;
@@ -2703,12 +3090,81 @@
2703 3090
2704 3091 wp_send_json_success([
2705 3092 'success' => $success,
2706 3093 'failed' => $failed,
3094 + 'denied' => $denied,
2707 3095 ]);
2708 3096 }
2709 3097
2710 3098 /**
3099 + * AJAX: Bulk unoptimize (revert) selected images.
3100 + */
3101 + public function ajax_bulk_unoptimize_selected()
3102 + {
3103 + check_ajax_referer('metasync_media_opt_nonce', 'nonce');
3104 +
3105 + if (!current_user_can('upload_files')) {
3106 + wp_send_json_error(__('Permission denied.', 'metasync'));
3107 + }
3108 +
3109 + $ids = isset($_POST['ids']) ? array_map('absint', explode(',', sanitize_text_field(wp_unslash($_POST['ids'])))) : [];
3110 + $ids = array_filter($ids);
3111 +
3112 + if (empty($ids)) {
3113 + wp_send_json_error(__('No images selected.', 'metasync'));
3114 + }
3115 +
3116 + $success = 0;
3117 + $failed = 0;
3118 + $skipped = 0;
3119 + $denied = 0;
3120 + $errors = [];
3121 +
3122 + foreach ($ids as $id) {
3123 + // Per-attachment ownership: reverting deletes the attachment's
3124 + // converted files, so restrict it per image, not per role.
3125 + if (!current_user_can('edit_post', $id)) {
3126 + $denied++;
3127 + continue;
3128 + }
3129 +
3130 + $format = get_post_meta($id, '_metasync_converted_format', true);
3131 +
3132 + if (!$format) {
3133 + $skipped++;
3134 + continue;
3135 + }
3136 +
3137 + if (!Metasync_Image_Converter::can_revert($id)) {
3138 + $skipped++;
3139 + $file = get_attached_file($id);
3140 + $name = $file ? basename($file) : "ID {$id}";
3141 + /* translators: %s: attachment file name. */
3142 + $errors[] = sprintf(__('%s: skipped — original image unavailable.', 'metasync'), $name);
3143 + continue;
3144 + }
3145 +
3146 + if (Metasync_Image_Converter::revert_attachment($id)) {
3147 + $success++;
3148 + } else {
3149 + $failed++;
3150 + $file = get_attached_file($id);
3151 + $name = $file ? basename($file) : "ID {$id}";
3152 + /* translators: %s: attachment file name. */
3153 + $errors[] = sprintf(__('%s: revert failed (original file may not exist).', 'metasync'), $name);
3154 + }
3155 + }
3156 +
3157 + wp_send_json_success([
3158 + 'success' => $success,
3159 + 'failed' => $failed,
3160 + 'skipped' => $skipped,
3161 + 'denied' => $denied,
3162 + 'errors' => $errors,
3163 + ]);
3164 + }
3165 +
3166 + /**
2711 3167 * AJAX: Process one batch tick (browser-driven chaining).
2712 3168 */
2713 3169 public function ajax_process_batch_tick()
2714 3170 {
@@ -2727,8 +3183,49 @@
2727 3183 wp_send_json_success($progress);
2728 3184 }
2729 3185
2730 3186 /**
3187 + * AJAX: Delete an orphaned media record (attachment whose file is missing).
3188 + *
3189 + * Only deletes when the file is confirmed missing on disk, so valid
3190 + * attachments can never be removed through this endpoint.
3191 + */
3192 + public function ajax_delete_orphaned_image()
3193 + {
3194 + check_ajax_referer('metasync_media_opt_nonce', 'nonce');
3195 +
3196 + $attachment_id = isset($_POST['attachment_id']) ? absint($_POST['attachment_id']) : 0;
3197 + if (!$attachment_id) {
3198 + wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
3199 + }
3200 +
3201 + if (!current_user_can('delete_post', $attachment_id)) {
3202 + wp_send_json_error(__('Permission denied.', 'metasync'));
3203 + }
3204 +
3205 + if (get_post_type($attachment_id) !== 'attachment') {
3206 + wp_send_json_error(__('Not an attachment.', 'metasync'));
3207 + }
3208 +
3209 + require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
3210 +
3211 + // Guard: only orphaned records (missing file) may be deleted here.
3212 + if (!Metasync_Media_Library_List_Table::is_file_missing($attachment_id)) {
3213 + wp_send_json_error(__('The image file exists; refusing to delete a valid attachment.', 'metasync'));
3214 + }
3215 +
3216 + $deleted = wp_delete_attachment($attachment_id, true);
3217 +
3218 + if ($deleted) {
3219 + wp_send_json_success([
3220 + 'stats' => Metasync_Media_Library_List_Table::get_stats(),
3221 + ]);
3222 + }
3223 +
3224 + wp_send_json_error(__('Failed to delete the orphaned media record.', 'metasync'));
3225 + }
3226 +
3227 + /**
2731 3228 * Cron handler: Process batch optimization tick.
2732 3229 */
2733 3230 public function handle_media_batch_cron()
2734 3231 {
@@ -2738,8 +3235,86 @@
2738 3235 Metasync_Media_Batch_Optimizer::process_batch_tick();
2739 3236 }
2740 3237
2741 3238 /**
3239 + * AJAX: Start SEO Restore batch.
3240 + */
3241 + public function ajax_seo_restore_start()
3242 + {
3243 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3244 +
3245 + if (!current_user_can('manage_options')) {
3246 + wp_send_json_error(__('Permission denied.', 'metasync'));
3247 + }
3248 +
3249 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3250 +
3251 + $progress = Metasync_Seo_Restore::start_batch();
3252 + wp_send_json_success($progress);
3253 + }
3254 +
3255 + /**
3256 + * AJAX: Cancel SEO Restore batch.
3257 + */
3258 + public function ajax_seo_restore_cancel()
3259 + {
3260 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3261 +
3262 + if (!current_user_can('manage_options')) {
3263 + wp_send_json_error(__('Permission denied.', 'metasync'));
3264 + }
3265 +
3266 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3267 +
3268 + Metasync_Seo_Restore::cancel_batch();
3269 + wp_send_json_success();
3270 + }
3271 +
3272 + /**
3273 + * AJAX: Get SEO Restore batch progress.
3274 + */
3275 + public function ajax_seo_restore_progress()
3276 + {
3277 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3278 +
3279 + if (!current_user_can('manage_options')) {
3280 + wp_send_json_error(__('Permission denied.', 'metasync'));
3281 + }
3282 +
3283 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3284 +
3285 + $progress = Metasync_Seo_Restore::get_progress();
3286 + wp_send_json_success($progress);
3287 + }
3288 +
3289 + /**
3290 + * AJAX: Process one SEO Restore tick (browser-driven chaining).
3291 + */
3292 + public function ajax_seo_restore_process_tick()
3293 + {
3294 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3295 +
3296 + if (!current_user_can('manage_options')) {
3297 + wp_send_json_error(__('Permission denied.', 'metasync'));
3298 + }
3299 +
3300 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3301 +
3302 + $progress = Metasync_Seo_Restore::process_ajax_tick();
3303 + wp_send_json_success($progress);
3304 + }
3305 +
3306 + /**
3307 + * Cron handler: Process SEO Restore batch tick.
3308 + */
3309 + public function handle_seo_restore_cron()
3310 + {
3311 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3312 +
3313 + Metasync_Seo_Restore::process_batch_tick();
3314 + }
3315 +
3316 + /**
2742 3317 * Report Issue page callback
2743 3318 */
2744 3319 public function create_admin_report_issue_page()
2745 3320 {
@@ -2751,8 +3326,17 @@
2751 3326 * XML Sitemap page callback
2752 3327 */
2753 3328 public function create_admin_xml_sitemap_page()
2754 3329 {
3330 + // Every form action below mutates site-wide state — sitemap options,
3331 + // sitemap files on disk, robots.txt, other plugins' sitemap generators
3332 + // — so they are restricted to site admins, not just anyone who can
3333 + // open the page via plugin_access_roles. The nonce is CSRF
3334 + // protection, not authorization.
3335 + if (!empty($_POST) && !current_user_can('manage_options')) {
3336 + wp_die(esc_html__('Sorry, you are not allowed to manage sitemap settings.', 'metasync'));
3337 + }
3338 +
2755 3339 // Load sitemap generator class
2756 3340 require_once plugin_dir_path(dirname(__FILE__)) . 'sitemap/class-metasync-sitemap-generator.php';
2757 3341
2758 3342 $sitemap_generator = new Metasync_Sitemap_Generator();
@@ -2764,17 +3348,56 @@
2764 3348 } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) {
2765 3349 $active_tab = sanitize_text_field(wp_unslash($_POST['redirect_tab']));
2766 3350 }
2767 3351
3352 + // Handle Main Sitemap settings form submission
3353 + if (isset($_POST['metasync_sitemap_settings_nonce']) && isset($_POST['save_sitemap_settings'])) {
3354 + check_admin_referer('metasync_sitemap_settings_action', 'metasync_sitemap_settings_nonce');
3355 +
3356 + $sitemap_settings = [
3357 + '_configured' => true,
3358 + 'post_types' => array_map('sanitize_key', (array) ($_POST['sitemap_post_types'] ?? [])),
3359 + 'categories' => array_map('absint', (array) ($_POST['sitemap_categories'] ?? [])),
3360 + 'tags' => array_map('absint', (array) ($_POST['sitemap_tags'] ?? [])),
3361 + 'taxonomies' => array_map('sanitize_key', (array) ($_POST['sitemap_taxonomies'] ?? [])),
3362 + 'excluded_urls' => sanitize_textarea_field(wp_unslash($_POST['sitemap_excluded_urls'] ?? '')),
3363 + ];
3364 +
3365 + update_option('metasync_sitemap_settings', $sitemap_settings);
3366 +
3367 + // Regenerate sitemap with new content settings
3368 + $result = $sitemap_generator->generate_sitemap( true );
3369 + if (is_wp_error($result)) {
3370 + echo '<div class="notice notice-error"><p>' . esc_html(
3371 + /* translators: %s: error message. */
3372 + sprintf(__('Settings saved but sitemap generation failed: %s', 'metasync'), $result->get_error_message())
3373 + ) . '</p></div>';
3374 + } elseif (false === $result) {
3375 + echo '<div class="notice notice-error"><p>' . esc_html__('Settings saved but sitemap generation failed: the sitemap data could not be stored, so no sitemap is being served. Check your object cache and database write settings, then try again.', 'metasync') . '</p></div>';
3376 + } else {
3377 + echo '<div class="notice notice-success"><p>' . esc_html__('Sitemap content settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3378 + }
3379 + }
3380 +
2768 3381 // Handle News Sitemap settings form submission
2769 3382 if (isset($_POST['metasync_news_sitemap_nonce']) && isset($_POST['save_news_sitemap'])) {
2770 3383 check_admin_referer('metasync_news_sitemap_action', 'metasync_news_sitemap_nonce');
2771 3384
3385 + // Build generic taxonomy filters
3386 + $news_taxonomies = [];
3387 + if (!empty($_POST['news_taxonomies']) && is_array($_POST['news_taxonomies'])) {
3388 + foreach (wp_unslash($_POST['news_taxonomies']) as $tax_name => $term_ids) {
3389 + $news_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) wp_unslash($term_ids));
3390 + }
3391 + }
3392 +
2772 3393 $news_settings = [
2773 3394 'enabled' => isset($_POST['news_enabled']),
2774 3395 'post_types' => array_map('sanitize_key', (array) ($_POST['news_post_types'] ?? ['post'])),
2775 3396 'categories' => array_map('absint', (array) ($_POST['news_categories'] ?? [])),
2776 3397 'tags' => array_map('absint', (array) ($_POST['news_tags'] ?? [])),
3398 + 'taxonomies' => $news_taxonomies,
3399 + 'excluded_urls' => sanitize_textarea_field(wp_unslash($_POST['news_excluded_urls'] ?? '')),
2777 3400 'publication_name' => sanitize_text_field(wp_unslash($_POST['publication_name'] ?? '')),
2778 3401 'publication_language' => sanitize_text_field(wp_unslash($_POST['publication_language'] ?? '')),
2779 3402 ];
2780 3403
@@ -2784,13 +3407,14 @@
2784 3407 update_option('metasync_news_sitemap_settings', $news_settings);
2785 3408
2786 3409 if ($news_settings['enabled']) {
2787 3410 $sitemap_generator->generate_news_sitemap();
3411 + update_option('metasync_sitemap_last_generated', current_time('mysql'));
2788 3412 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
2789 3413 } else {
2790 3414 // Also remove physical file if it exists
2791 3415 if (file_exists(ABSPATH . 'news-sitemap.xml')) {
2792 - @unlink(ABSPATH . 'news-sitemap.xml');
3416 + @wp_delete_file(ABSPATH . 'news-sitemap.xml');
2793 3417 }
2794 3418 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
2795 3419 }
2796 3420 }
@@ -2798,12 +3422,26 @@
2798 3422 // Handle Video Sitemap settings form submission
2799 3423 if (isset($_POST['metasync_video_sitemap_nonce']) && isset($_POST['save_video_sitemap'])) {
2800 3424 check_admin_referer('metasync_video_sitemap_action', 'metasync_video_sitemap_nonce');
2801 3425
3426 + // Build generic taxonomy filters
3427 + $video_taxonomies = [];
3428 + if (!empty($_POST['video_taxonomies']) && is_array($_POST['video_taxonomies'])) {
3429 + foreach (wp_unslash($_POST['video_taxonomies']) as $tax_name => $term_ids) {
3430 + $video_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) wp_unslash($term_ids));
3431 + }
3432 + }
3433 +
2802 3434 $video_settings = [
2803 - 'enabled' => isset($_POST['video_enabled']),
2804 - 'post_types' => array_map('sanitize_key', (array) ($_POST['video_post_types'] ?? ['post', 'page'])),
2805 - 'auto_detect' => isset($_POST['auto_detect']),
3435 + 'enabled' => isset($_POST['video_enabled']),
3436 + 'post_types' => array_map('sanitize_key', (array) ($_POST['video_post_types'] ?? ['post', 'page'])),
3437 + 'auto_detect' => isset($_POST['auto_detect']),
3438 + 'taxonomies' => $video_taxonomies,
3439 + 'excluded_urls' => sanitize_textarea_field(wp_unslash($_POST['video_excluded_urls'] ?? '')),
3440 + // This array replaces the stored option wholesale, so every
3441 + // setting must be represented here or it is wiped on save.
3442 + 'video_url_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_url_meta_keys'] ?? '')),
3443 + 'video_thumbnail_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_thumbnail_meta_keys'] ?? '')),
2806 3444 ];
2807 3445
2808 3446 // Always invalidate old cache before saving new settings
2809 3447 delete_transient('metasync_vsm_' . md5('video-sitemap.xml'));
@@ -2811,13 +3449,14 @@
2811 3449 update_option('metasync_video_sitemap_settings', $video_settings);
2812 3450
2813 3451 if ($video_settings['enabled']) {
2814 3452 $sitemap_generator->generate_video_sitemap();
3453 + update_option('metasync_sitemap_last_generated', current_time('mysql'));
2815 3454 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
2816 3455 } else {
2817 3456 // Also remove physical file if it exists
2818 3457 if (file_exists(ABSPATH . 'video-sitemap.xml')) {
2819 - @unlink(ABSPATH . 'video-sitemap.xml');
3458 + @wp_delete_file(ABSPATH . 'video-sitemap.xml');
2820 3459 }
2821 3460 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
2822 3461 }
2823 3462 }
@@ -2860,8 +3499,17 @@
2860 3499 if ($sitemap_generator->generate_news_sitemap()) {
2861 3500 $extras[] = 'news';
2862 3501 }
2863 3502 }
3503 +
3504 + // Reclaim unreferenced memory between the news and video passes
3505 + // so the video generator starts with more headroom (they share
3506 + // one request). Each generator already releases its own object
3507 + // cache via clean_post_cache(), so no cache flush is needed here.
3508 + if (function_exists('gc_collect_cycles')) {
3509 + gc_collect_cycles();
3510 + }
3511 +
2864 3512 if (!empty($video_opts['enabled'])) {
2865 3513 if ($sitemap_generator->generate_video_sitemap()) {
2866 3514 $extras[] = 'video';
2867 3515 }
@@ -2866,18 +3514,30 @@
2866 3514 $extras[] = 'video';
2867 3515 }
2868 3516 }
2869 3517
3518 + // Same inter-pass GC before the main sitemap pass begins.
3519 + if (function_exists('gc_collect_cycles')) {
3520 + gc_collect_cycles();
3521 + }
3522 +
2870 3523 // Generate main sitemap (its index will include news/video since they now exist)
2871 - $result = $sitemap_generator->generate_sitemap();
3524 + $result = $sitemap_generator->generate_sitemap( true );
2872 3525
2873 3526 if (is_wp_error($result)) {
2874 - error_log('[MetaSync] Sitemap generation failed: ' . $result->get_error_message());
2875 - echo '<div class="notice notice-error"><p>' . esc_html__('Sitemap generation failed. Please try again or check the error logs.', 'metasync') . '</p></div>';
3527 + $error_msg = $result->get_error_message();
3528 + error_log('[MetaSync] Sitemap generation failed: ' . $error_msg); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3529 + echo '<div class="notice notice-error"><p>' . esc_html(
3530 + /* translators: %s: error message. */
3531 + sprintf(__('Sitemap generation failed: %s', 'metasync'), $error_msg)
3532 + ) . '</p></div>';
3533 + } elseif (false === $result) {
3534 + echo '<div class="notice notice-error"><p>' . esc_html__('Sitemap generation failed: the sitemap data could not be stored, so no sitemap is being served. Check your object cache and database write settings, then try again.', 'metasync') . '</p></div>';
2876 3535 } else {
2877 3536 $message = esc_html__('Sitemap generated successfully!', 'metasync');
2878 3537 if (!empty($extras)) {
2879 3538 $message .= ' ' . sprintf(
3539 + /* translators: %s: list of sitemap types. */
2880 3540 esc_html__('Also generated %s sitemap(s).', 'metasync'),
2881 3541 implode(' & ', $extras)
2882 3542 );
2883 3543 }
@@ -2906,8 +3566,38 @@
2906 3566 echo '<div class="notice notice-success"><p>' . esc_html__('Auto-update enabled!', 'metasync') . '</p></div>';
2907 3567 } elseif (isset($_POST['disable_auto_update'])) {
2908 3568 update_option('metasync_sitemap_auto_update', false);
2909 3569 echo '<div class="notice notice-success"><p>' . esc_html__('Auto-update disabled!', 'metasync') . '</p></div>';
3570 + } elseif (isset($_POST['delete_general_sitemap'])) {
3571 + $deleted = $sitemap_generator->delete_sitemap('general');
3572 +
3573 + if ($deleted) {
3574 + // Disable auto-update only when the general sitemap is removed
3575 + update_option('metasync_sitemap_auto_update', false);
3576 + // Re-enable WP core sitemap only if no other MetaSync sitemaps remain
3577 + if (!$sitemap_generator->sitemap_exists()) {
3578 + delete_option('metasync_disable_wp_sitemap');
3579 + }
3580 + echo '<div class="notice notice-success"><p>' . esc_html__('General sitemap deleted successfully!', 'metasync') . '</p></div>';
3581 + } else {
3582 + 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>';
3583 + }
3584 + } elseif (isset($_POST['delete_news_sitemap'])) {
3585 + $deleted = $sitemap_generator->delete_sitemap('news');
3586 +
3587 + if ($deleted) {
3588 + echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap deleted successfully!', 'metasync') . '</p></div>';
3589 + } else {
3590 + 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>';
3591 + }
3592 + } elseif (isset($_POST['delete_video_sitemap'])) {
3593 + $deleted = $sitemap_generator->delete_sitemap('video');
3594 +
3595 + if ($deleted) {
3596 + echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap deleted successfully!', 'metasync') . '</p></div>';
3597 + } else {
3598 + 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>';
3599 + }
2910 3600 } elseif (isset($_POST['delete_sitemap'])) {
2911 3601 // Delete all sitemaps: main + news + video (handled by delete_sitemap)
2912 3602 $deleted = $sitemap_generator->delete_sitemap();
2913 3603
@@ -2913,8 +3603,10 @@
2913 3603
2914 3604 if ($deleted) {
2915 3605 // Also disable auto-update when deleting
2916 3606 update_option('metasync_sitemap_auto_update', false);
3607 + // Re-enable WP core sitemap so the site isn't left with zero sitemaps
3608 + delete_option('metasync_disable_wp_sitemap');
2917 3609 echo '<div class="notice notice-success"><p>' . esc_html__('All sitemaps deleted successfully!', 'metasync') . '</p></div>';
2918 3610 } else {
2919 3611 echo '<div class="notice notice-error"><p>' . esc_html__('Failed to delete sitemaps. The files may not exist or are not writable.', 'metasync') . '</p></div>';
2920 3612 }
@@ -2936,8 +3628,17 @@
2936 3628 $last_generated = $sitemap_generator->get_last_generated_time();
2937 3629 $auto_update_enabled = get_option('metasync_sitemap_auto_update', false);
2938 3630 $active_sitemap_plugins = $sitemap_generator->check_active_sitemap_plugins();
2939 3631
3632 + // Main sitemap content settings
3633 + $sitemap_settings = get_option('metasync_sitemap_settings', [
3634 + 'post_types' => [],
3635 + 'categories' => [],
3636 + 'tags' => [],
3637 + 'taxonomies' => [],
3638 + 'excluded_urls' => '',
3639 + ]);
3640 +
2940 3641 // News and video sitemap settings for tabs
2941 3642 $news_settings = get_option('metasync_news_sitemap_settings', [
2942 3643 'enabled' => false,
2943 3644 'post_types' => ['post'],
@@ -2942,15 +3643,19 @@
2942 3643 'enabled' => false,
2943 3644 'post_types' => ['post'],
2944 3645 'categories' => [],
2945 3646 'tags' => [],
3647 + 'taxonomies' => [],
3648 + 'excluded_urls' => '',
2946 3649 'publication_name' => '',
2947 3650 'publication_language' => '',
2948 3651 ]);
2949 3652 $video_settings = get_option('metasync_video_sitemap_settings', [
2950 - 'enabled' => false,
2951 - 'post_types' => ['post', 'page'],
2952 - 'auto_detect' => true,
3653 + 'enabled' => false,
3654 + 'post_types' => ['post', 'page'],
3655 + 'auto_detect' => true,
3656 + 'taxonomies' => [],
3657 + 'excluded_urls' => '',
2953 3658 ]);
2954 3659
2955 3660 // Load view
2956 3661 require_once plugin_dir_path(dirname(__FILE__)) . 'views/metasync-xml-sitemap.php';
@@ -3021,47 +3726,8 @@
3021 3726 Metasync_Admin_Pages::get_instance($this)->create_admin_breadcrumbs_page();
3022 3727 }
3023 3728
3024 3729 /**
3025 - * Google Instant Index Setting page callback
3026 - */
3027 - public function create_admin_google_instant_index_page()
3028 - {
3029 - $this->render_layout_open('Instant Indexing', 'instant_index', 'Submit URLs to Google for instant indexing via the Indexing API.');
3030 -
3031 - // Render shared Google Index credentials section
3032 - if (!function_exists('google_index_direct')) {
3033 - if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) {
3034 - require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
3035 - } else {
3036 - error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php');
3037 - return;
3038 - }
3039 - }
3040 - $google_index = google_index_direct();
3041 - $service_info = $google_index->get_service_account_info();
3042 - $is_configured = !isset($service_info['error']);
3043 -
3044 - $saved_json_display = $is_configured ? $google_index->get_redacted_config_json() : '';
3045 -
3046 - include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-index-api-settings.php';
3047 -
3048 - // Render post types selection with save form
3049 - $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
3050 - $post_types_settings = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
3051 - ?>
3052 - <form method="POST" action="">
3053 - <?php include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-instant-post-types.php'; ?>
3054 - <div class="dashboard-card" style="padding: 20px;">
3055 - <?php submit_button('Save Post Types', 'primary', 'submit', false, array('class' => 'button button-primary')); ?>
3056 - </div>
3057 - </form>
3058 - <?php
3059 -
3060 - $this->render_layout_close();
3061 - }
3062 -
3063 - /**
3064 3730 * Google Console page callback
3065 3731 */
3066 3732 public function create_admin_google_console_page()
3067 3733 {
@@ -3084,16 +3750,8 @@
3084 3750 $this->render_layout_close();
3085 3751 }
3086 3752
3087 3753 /**
3088 - * General Options page callback
3089 - */
3090 - public function create_admin_optimal_settings_page()
3091 - {
3092 - Metasync_Admin_Pages::get_instance($this)->create_admin_optimal_settings_page();
3093 - }
3094 -
3095 - /**
3096 3754 * Global Options page callback
3097 3755 */
3098 3756 public function create_admin_global_settings_page()
3099 3757 {
@@ -3125,16 +3783,8 @@
3125 3783 Metasync_Admin_Pages::get_instance($this)->create_admin_seo_controls_page();
3126 3784 }
3127 3785
3128 3786 /**
3129 - * Site Optimal Settings page callback
3130 - */
3131 - public function optimization_settings_options()
3132 - {
3133 - Metasync_Admin_Pages::get_instance($this)->optimization_settings_options();
3134 - }
3135 -
3136 - /**
3137 3787 * redirection page callback with tabs
3138 3788 */
3139 3789 public function create_admin_redirections_page()
3140 3790 {
@@ -3161,10 +3811,11 @@
3161 3811 }
3162 3812 delete_transient( Metasync_CPU_Monitor::DEFER_NOTICE_TRANSIENT );
3163 3813 echo '<div class="notice notice-warning is-dismissible"><p>';
3164 3814 printf(
3165 - /* translators: 1: current load, 2: threshold, 3: core count */
3166 - esc_html__( 'MetaSync: Batch processing was deferred — server CPU load (%1$s) exceeded the threshold (%2$s on %3$s cores). Processing will resume automatically.', 'metasync' ),
3815 + /* translators: 1: plugin name, 2: current load, 3: threshold, 4: core count */
3816 + esc_html__( '%1$s: Batch processing was deferred — server CPU load (%2$s) exceeded the threshold (%3$s on %4$s cores). Processing will resume automatically.', 'metasync' ),
3817 + esc_html( Metasync::get_effective_plugin_name() ),
3167 3818 esc_html( $data['load'] ),
3168 3819 esc_html( $data['threshold'] ),
3169 3820 esc_html( $data['cores'] )
3170 3821 );
@@ -3182,9 +3833,10 @@
3182 3833 if (!get_transient('metasync_llms_conflict')) {
3183 3834 return;
3184 3835 }
3185 3836 echo '<div class="notice notice-info is-dismissible"><p>';
3186 - echo esc_html__('Note: Another SEO plugin (Yoast, Rank Math, or AIOSEO) may also be generating /llms.txt. MetaSync\'s version takes priority when enabled.', 'metasync');
3837 + /* translators: %s: effective plugin name (whitelabel-aware). */
3838 + echo esc_html(sprintf(__('Note: Another SEO plugin (Yoast, Rank Math, or AIOSEO) may also be generating /llms.txt. %s\'s version takes priority when enabled.', 'metasync'), Metasync::get_effective_plugin_name()));
3187 3839 echo '</p></div>';
3188 3840 }
3189 3841
3190 3842 /**
@@ -3253,8 +3905,17 @@
3253 3905 public function ajax_restore_robots_backup()
3254 3906 {
3255 3907 Metasync_Admin_Ajax::instance()->ajax_restore_robots_backup();
3256 3908 }
3909 +
3910 + /**
3911 + * AJAX handler to fetch a paginated page of robots.txt backups
3912 + */
3913 + public function ajax_get_robots_backups()
3914 + {
3915 + Metasync_Admin_Ajax::instance()->ajax_get_robots_backups();
3916 + }
3917 +
3257 3918 public function ajax_create_redirect_from_404()
3258 3919 {
3259 3920 Metasync_Admin_Ajax::instance()->ajax_create_redirect_from_404();
3260 3921 }
@@ -3282,9 +3943,9 @@
3282 3943 {
3283 3944 register_rest_route('metasync/v1', '/ping', array(
3284 3945 'methods' => array('GET', 'POST'),
3285 3946 'callback' => array($this, 'handle_ping_rest_endpoint'),
3286 - 'permission_callback' => '__return_true', // Allow public access
3947 + 'permission_callback' => '__return_true',
3287 3948 'args' => array(
3288 3949 'test' => array(
3289 3950 'description' => 'Optional test parameter',
3290 3951 'type' => 'string',
@@ -3366,17 +4027,17 @@
3366 4027 return;
3367 4028 }
3368 4029
3369 4030 // Get pagination parameters
3370 - $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
3371 - $per_page = 10;
4031 + $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
4032 + $per_page = Metasync_Per_Page_Helper::resolve('sync_log', 10);
3372 4033 $offset = ($page - 1) * $per_page;
3373 4034
3374 4035 // Get filters
3375 4036 $filters = [
3376 4037 // UI exposes date_range and status only. We compute date_from/date_to based on date_range
3377 - 'date_range' => isset($_GET['date_range']) ? sanitize_text_field(wp_unslash($_GET['date_range'])) : '',
3378 - 'status' => isset($_GET['status']) ? sanitize_text_field(wp_unslash($_GET['status'])) : '',
4038 + 'date_range' => isset($_GET['date_range']) ? sanitize_text_field(wp_unslash($_GET['date_range'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
4039 + 'status' => isset($_GET['status']) ? sanitize_text_field(wp_unslash($_GET['status'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
3379 4040 ];
3380 4041
3381 4042 // Map date_range to concrete date_from/date_to for DB queries
3382 4043 $date_range = $filters['date_range'];
@@ -3385,28 +4046,28 @@
3385 4046 $date_to = '';
3386 4047
3387 4048 if (!empty($date_range)) {
3388 4049 // End boundary is now by default
3389 - $date_to = date('Y-m-d H:i:s', $wp_now_ts);
4050 + $date_to = gmdate('Y-m-d H:i:s', $wp_now_ts);
3390 4051
3391 4052 if ($date_range === 'today') {
3392 4053 $start_ts = strtotime('today', $wp_now_ts);
3393 - $date_from = date('Y-m-d H:i:s', $start_ts);
4054 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3394 4055 } elseif ($date_range === 'yesterday') {
3395 4056 $start_ts = strtotime('yesterday', $wp_now_ts);
3396 4057 $end_ts = strtotime('today', $wp_now_ts) - 1; // end of yesterday
3397 - $date_from = date('Y-m-d H:i:s', $start_ts);
3398 - $date_to = date('Y-m-d H:i:s', $end_ts);
4058 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
4059 + $date_to = gmdate('Y-m-d H:i:s', $end_ts);
3399 4060 } elseif ($date_range === 'this_week') {
3400 4061 $start_of_week = (int) get_option('start_of_week', 1); // 0=Sun, 1=Mon
3401 - $day_of_week = (int) date('w', $wp_now_ts); // 0=Sun..6=Sat
4062 + $day_of_week = (int) gmdate('w', $wp_now_ts); // 0=Sun..6=Sat
3402 4063 // Convert start_of_week to PHP's 0..6 where 0=Sunday
3403 4064 $delta_days = ($day_of_week - $start_of_week + 7) % 7;
3404 4065 $start_ts = strtotime('-' . $delta_days . ' days', strtotime('today', $wp_now_ts));
3405 - $date_from = date('Y-m-d H:i:s', $start_ts);
4066 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3406 4067 } elseif ($date_range === 'this_month') {
3407 - $start_ts = strtotime(date('Y-m-01 00:00:00', $wp_now_ts));
3408 - $date_from = date('Y-m-d H:i:s', $start_ts);
4068 + $start_ts = strtotime(gmdate('Y-m-01 00:00:00', $wp_now_ts));
4069 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3409 4070 } elseif ($date_range === 'all') {
3410 4071 // no bounds
3411 4072 }
3412 4073 }
@@ -3433,12 +4094,24 @@
3433 4094 ?>
3434 4095 <div class="dashboard-card">
3435 4096 <div class="sync-log-header">
3436 4097 <div class="sync-log-title-section">
3437 - <h2>Changes Log</h2>
3438 - <p style="color: var(--dashboard-text-secondary); margin-bottom: 0;">
3439 - Recent content synchronizations from external tools.
3440 - <span style="margin-left:8px; font-size:12px; opacity:.75;">Records are automatically removed after 90 days.</span>
4098 + <?php
4099 + // The page title and description are already rendered by
4100 + // render_layout_open() above. Repeating them here printed
4101 + // "Changes Log" twice on screen, so the card keeps only the
4102 + // retention hint — the one piece of copy the layout header
4103 + // does not carry.
4104 + ?>
4105 + <p style="color: var(--dashboard-text-secondary); margin-top: 0; margin-bottom: 0;">
4106 + <?php
4107 + // No opacity here: this used to be a de-emphasised
4108 + // aside trailing a full-contrast sentence, but it is
4109 + // now the card's only copy. At 12px/.75 it composites
4110 + // to 4.28:1 on the card background, under the 4.5:1
4111 + // AA floor; at full strength it reads 6.52:1.
4112 + ?>
4113 + <span style="font-size:13px;">Records are automatically removed after 90 days.</span>
3441 4114 </p>
3442 4115 </div>
3443 4116
3444 4117 <!-- Filters + Clear Log button - Right aligned -->
@@ -3447,10 +4120,10 @@
3447 4120 style="background:#dc3545;color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:13px;"
3448 4121 data-nonce="<?php echo esc_attr(wp_create_nonce('metasync_clear_sync_log')); ?>">
3449 4122 🗑 Clear Log
3450 4123 </button>
3451 - <form method="get" class="sync-filters-form" onchange="this.submit()">
3452 - <input type="hidden" name="page" value="<?php echo esc_attr($_GET['page']); ?>">
4124 + <form method="get" class="sync-filters-form" onchange="this.submit()" style="display:flex;flex-direction:row;align-items:center;gap:12px;flex-wrap:nowrap;">
4125 + <input type="hidden" name="page" value="<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>">
3453 4126
3454 4127 <select name="date_range" class="sync-filter-select">
3455 4128 <option value="all" <?php selected($filters['date_range'] ?? 'all', 'all'); ?>> All Time</option>
3456 4129 <option value="today" <?php selected($filters['date_range'] ?? '', 'today'); ?>>Today</option>
@@ -3472,9 +4145,9 @@
3472 4145 <div class="sync-log-list">
3473 4146 <?php if (empty($sync_records)): ?>
3474 4147 <div class="sync-log-empty">
3475 4148 <div class="sync-log-empty-icon"><span class="dashicons dashicons-media-default" style="font-size:48px;width:48px;height:48px;color:var(--dashboard-text-secondary);"></span></div>
3476 - <h3>No sync records found</h3>
4149 + <h2>No sync records found</h2>
3477 4150 <p>Sync records will appear here when content/pages receive new updates.</p>
3478 4151 </div>
3479 4152 <?php else: ?>
3480 4153 <?php foreach ($sync_records as $record): ?>
@@ -3499,19 +4172,20 @@
3499 4172 </div>
3500 4173 </div>
3501 4174
3502 4175 <div class="sync-log-status" style="display:flex;align-items:center;gap:8px;">
3503 - <?php if ($record->status === 'published' || $record->status === 'publish'): ?>
3504 - <span class="sync-status-badge sync-status-published">
3505 - <span class="sync-status-icon">✓</span>
3506 - Published
3507 - </span>
3508 - <?php else: ?>
3509 - <span class="sync-status-badge sync-status-draft">
3510 - <span class="sync-status-icon">i</span>
3511 - Draft
3512 - </span>
3513 - <?php endif; ?>
4176 + <?php
4177 + $st = (string) $record->status;
4178 + $b_label = ucfirst($st); $b_bg = '#6b7280'; $b_icon = 'dashicons-info-outline';
4179 + if ($st === 'published' || $st === 'publish' || $st === 'success' || $st === 'partial') { $b_label = 'Published'; $b_bg = '#16a34a'; $b_icon = 'dashicons-yes'; }
4180 + elseif ($st === 'updated') { $b_label = 'Updated'; $b_bg = '#0d9488'; $b_icon = 'dashicons-update'; }
4181 + elseif ($st === 'failed' || $st === 'conflict' || $st === 'locked') { $b_label = 'Not imported'; $b_bg = '#64748b'; $b_icon = 'dashicons-minus'; }
4182 + elseif ($st === 'draft') { $b_label = 'Draft'; $b_bg = '#6b7280'; $b_icon = 'dashicons-info-outline'; }
4183 + ?>
4184 + <span class="sync-status-badge" style="display:inline-flex;align-items:center;gap:4px;background:<?php echo esc_attr($b_bg); ?>;color:#fff;padding:3px 10px;border-radius:12px;font-size:12px;font-weight:600;line-height:1;">
4185 + <span class="dashicons <?php echo esc_attr($b_icon); ?>" style="font-size:14px;width:14px;height:14px;line-height:14px;"></span>
4186 + <?php echo esc_html($b_label); ?>
4187 + </span>
3514 4188 <?php if ($record->source === 'MCP Client'): ?>
3515 4189 <button type="button"
3516 4190 class="metasync-rollback-btn"
3517 4191 data-id="<?php echo esc_attr($record->id); ?>"
@@ -3527,30 +4201,32 @@
3527 4201 <?php endif; ?>
3528 4202 </div>
3529 4203
3530 4204 <!-- Pagination -->
3531 - <?php if ($total_pages > 1): ?>
3532 - <div class="sync-log-pagination">
3533 - <div class="sync-log-pagination-info">
3534 - Total records: <?php echo intval( $total_records ); ?> | Showing <?php echo intval( $offset ) + 1; ?>-<?php echo intval( min($offset + $per_page, $total_records) ); ?>
3535 - </div>
4205 + <div class="sync-log-pagination">
4206 + <div class="sync-log-pagination-info">
4207 + Total records: <?php echo intval( $total_records ); ?><?php if ($total_records > 0): ?> | Showing <?php echo intval( $offset ) + 1; ?>-<?php echo intval( min($offset + $per_page, $total_records) ); ?><?php endif; ?>
4208 + <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper returns a complete escaped selector element.
4209 + echo Metasync_Per_Page_Helper::render_selector('sync_log', absint($per_page)); ?>
4210 + </div>
3536 4211
4212 + <?php if ($total_pages > 1): ?>
3537 4213 <div class="sync-log-pagination-controls">
3538 4214 <?php if ($page > 1): ?>
3539 - <a href="?page=<?php echo esc_attr($_GET['page']); ?>&paged=<?php echo intval( $page ) - 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">‹</a>
4215 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $page ) - 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">‹</a>
3540 4216 <?php endif; ?>
3541 4217
3542 4218 <?php for ($i = max(1, $page - 2); $i <= min($total_pages, $page + 2); $i++): ?>
3543 - <a href="?page=<?php echo esc_attr($_GET['page']); ?>&paged=<?php echo intval( $i ); ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>"
4219 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $i ); ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>"
3544 4220 class="sync-pagination-btn <?php echo $i === $page ? 'active' : ''; ?>"><?php echo intval( $i ); ?></a>
3545 4221 <?php endfor; ?>
3546 4222
3547 4223 <?php if ($page < $total_pages): ?>
3548 - <a href="?page=<?php echo esc_attr($_GET['page']); ?>&paged=<?php echo intval( $page ) + 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">›</a>
4224 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $page ) + 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">›</a>
3549 4225 <?php endif; ?>
3550 4226 </div>
3551 - </div>
3552 - <?php endif; ?>
4227 + <?php endif; ?>
4228 + </div>
3553 4229 </div>
3554 4230 <?php $this->render_layout_close(); ?>
3555 4231
3556 4232 <script>
@@ -3631,8 +4307,19 @@
3631 4307 if (!empty($value)) {
3632 4308 $query_parts[] = $key . '=' . urlencode($value);
3633 4309 }
3634 4310 }
4311 +
4312 + // Preserve the user-selected results-per-page value across page
4313 + // navigation so a non-default page size survives clicking a page link.
4314 + $per_page_key = Metasync_Per_Page_Helper::request_key('sync_log');
4315 + if (isset($_GET[$per_page_key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4316 + $per_page = (int) $_GET[$per_page_key]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4317 + if (in_array($per_page, Metasync_Per_Page_Helper::allowed_values(), true)) {
4318 + $query_parts[] = $per_page_key . '=' . $per_page;
4319 + }
4320 + }
4321 +
3635 4322 return !empty($query_parts) ? '&' . implode('&', $query_parts) : '';
3636 4323 }
3637 4324
3638 4325 /**
@@ -3806,18 +4493,8 @@
3806 4493 {
3807 4494 Metasync_Connect_Manager::instance()->handle_session_management_early();
3808 4495 }
3809 4496
3810 - /**
3811 - * @deprecated 2.5.12 Use Metasync_Auth_Manager instead of sessions for authentication
3812 - */
3813 - private function safe_session_start() {
3814 - // This method is deprecated and no longer used
3815 - // Authentication now uses Metasync_Auth_Manager with WordPress transients and user meta
3816 - _deprecated_function(__METHOD__, '2.5.12', 'Metasync_Auth_Manager');
3817 - return Metasync_Session_Helper::safe_start();
3818 - }
3819 -
3820 4497 private function handle_whitelabel_session_logic()
3821 4498 {
3822 4499 Metasync_Connect_Manager::instance()->handle_whitelabel_session_logic();
3823 4500 }
@@ -3863,8 +4540,9 @@
3863 4540 $stats = Metasync_CPU_Monitor::get_stats();
3864 4541 $per_core_threshold = Metasync_CPU_Monitor::get_per_core_threshold();
3865 4542 $cores = Metasync_CPU_Monitor::get_cpu_core_count();
3866 4543 $effective_threshold = Metasync_CPU_Monitor::get_effective_threshold();
4544 + $detection_reliable = Metasync_CPU_Monitor::is_core_detection_reliable();
3867 4545 ?>
3868 4546 <div style="background: var(--dashboard-card-bg); padding: 20px; border-radius: 8px;">
3869 4547 <!-- CPU Cores Detected -->
3870 4548 <div style="margin-bottom: 24px;">
@@ -3871,12 +4549,20 @@
3871 4549 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
3872 4550 CPU Cores Detected
3873 4551 </label>
3874 4552 <div style="padding: 10px 12px; background: var(--dashboard-input-bg); border: 1px solid var(--dashboard-border); border-radius: 6px; color: var(--dashboard-text-secondary);">
3875 - <strong><?php echo intval($cores); ?></strong> core<?php echo $cores !== 1 ? 's' : ''; ?>
4553 + <?php if ($detection_reliable) : ?>
4554 + <strong><?php echo intval($cores); ?></strong> core<?php echo $cores !== 1 ? 's' : ''; ?>
4555 + <?php else : ?>
4556 + <strong>Not detected</strong>
4557 + <?php endif; ?>
3876 4558 </div>
3877 4559 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
3878 - Automatically detected on this system.
4560 + <?php if ($detection_reliable) : ?>
4561 + Automatically detected on this system.
4562 + <?php else : ?>
4563 + Core detection is not available on this hosting environment.
4564 + <?php endif; ?>
3879 4565 </p>
3880 4566 </div>
3881 4567
3882 4568 <!-- Per-Core Load Threshold -->
@@ -3882,8 +4568,9 @@
3882 4568 <!-- Per-Core Load Threshold -->
3883 4569 <div style="margin-bottom: 24px;">
3884 4570 <label for="cpu_load_per_core_threshold" style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
3885 4571 Per-Core Load Threshold
4572 + <?php Metasync::render_tooltip_icon('cpu_per_core_load_threshold', 'How busy each CPU core can get before the plugin pauses background SEO syncing. Lower = more cautious. Most sites leave this at 2.0.'); ?>
3886 4573 </label>
3887 4574 <input type="number"
3888 4575 id="cpu_load_per_core_threshold"
3889 4576 name="metasync_options[performance][cpu_load_per_core_threshold]"
@@ -3903,9 +4590,9 @@
3903 4590 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
3904 4591 Effective Threshold
3905 4592 </label>
3906 4593 <div style="padding: 10px 12px; background: var(--dashboard-input-bg); border: 1px solid var(--dashboard-border); border-radius: 6px; color: var(--dashboard-text-secondary);">
3907 - <strong id="effective_threshold_value"><?php echo round($effective_threshold, 2); ?></strong>
4594 + <strong id="effective_threshold_value"><?php echo esc_html((string) round($effective_threshold, 2)); ?></strong>
3908 4595 </div>
3909 4596 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
3910 4597 Calculated as: cores × per-core threshold
3911 4598 </p>
@@ -3923,13 +4610,13 @@
3923 4610 <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo intval($stats['deferrals']); ?></div>
3924 4611 </div>
3925 4612 <div>
3926 4613 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Max Load Observed</div>
3927 - <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo round($stats['max_load'], 2); ?></div>
4614 + <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo esc_html((string) round($stats['max_load'], 2)); ?></div>
3928 4615 </div>
3929 4616 <div>
3930 4617 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Average Load</div>
3931 - <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo round($stats['avg_load'], 2); ?></div>
4618 + <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo esc_html((string) round($stats['avg_load'], 2)); ?></div>
3932 4619 </div>
3933 4620 </div>
3934 4621 </div>
3935 4622
@@ -4435,88 +5122,8 @@
4435 5122
4436 5123 /**
4437 5124 * Get the settings option array and print one of its values
4438 5125 */
4439 - public function no_index_posts_callback()
4440 - {
4441 - Metasync_Settings_Fields::instance()->no_index_posts_callback();
4442 - }
4443 -
4444 - /**
4445 - * Get the settings option array and print one of its values
4446 - */
4447 - public function no_follow_links_callback()
4448 - {
4449 - Metasync_Settings_Fields::instance()->no_follow_links_callback();
4450 - }
4451 -
4452 - /**
4453 - * Get the settings option array and print one of its values
4454 - */
4455 - public function open_external_links_callback()
4456 - {
4457 - Metasync_Settings_Fields::instance()->open_external_links_callback();
4458 - }
4459 -
4460 - /**
4461 - * Get the settings option array and print one of its values
4462 - */
4463 - public function add_alt_image_tags_callback()
4464 - {
4465 - Metasync_Settings_Fields::instance()->add_alt_image_tags_callback();
4466 - }
4467 -
4468 - /**
4469 - * Get the settings option array and print one of its values
4470 - */
4471 - public function add_title_image_tags_callback()
4472 - {
4473 - Metasync_Settings_Fields::instance()->add_title_image_tags_callback();
4474 - }
4475 -
4476 - /**
4477 - * Get the settings option array and print one of its values
4478 - */
4479 - public function site_type_callback()
4480 - {
4481 - Metasync_Settings_Fields::instance()->site_type_callback();
4482 - }
4483 -
4484 - /**
4485 - * Get the settings option array and print one of its values
4486 - */
4487 - public function site_business_type_callback()
4488 - {
4489 - Metasync_Settings_Fields::instance()->site_business_type_callback();
4490 - }
4491 -
4492 - /**
4493 - * Get the settings option array and print one of its values
4494 - */
4495 - public function site_company_name_callback()
4496 - {
4497 - Metasync_Settings_Fields::instance()->site_company_name_callback();
4498 - }
4499 -
4500 - /**
4501 - * Get the settings option array and print one of its values
4502 - */
4503 - public function site_google_logo_callback()
4504 - {
4505 - Metasync_Settings_Fields::instance()->site_google_logo_callback();
4506 - }
4507 -
4508 - /**
4509 - * Get the settings option array and print one of its values
4510 - */
4511 - public function site_social_share_image_callback()
4512 - {
4513 - Metasync_Settings_Fields::instance()->site_social_share_image_callback();
4514 - }
4515 -
4516 - /**
4517 - * Get the settings option array and print one of its values
4518 - */
4519 5126 public function common_robot_meta_tags_callback()
4520 5127 {
4521 5128 Metasync_Settings_Fields::instance()->common_robot_meta_tags_callback();
4522 5129 }
@@ -4693,13 +5300,11 @@
4693 5300 $current_permalink_structure = get_option('permalink_structure');
4694 5301
4695 5302 # Get the plugin name using centralized method
4696 5303 $plugin_name = Metasync::get_effective_plugin_name();
4697 - $current_rewrite_rules = get_option('rewrite_rules');
5304 +
4698 5305 # Check if the current permalink structure is set to "Plain"
4699 - if (($current_permalink_structure == '/%post_id%/' || $current_permalink_structure == '') && $current_rewrite_rules == '') {
4700 -
4701 - # Show admin notice with plugin name included in the message
5306 + if ($current_permalink_structure === '/%post_id%/' || $current_permalink_structure === '') {
4702 5307 printf(
4703 5308 '<div class="notice notice-error is-dismissible">
4704 5309 <p>
4705 5310 <b>Warning from %s</b><br>
@@ -4709,9 +5314,8 @@
4709 5314 </div>',
4710 5315 esc_html($plugin_name)
4711 5316 );
4712 5317 }
4713 - flush_rewrite_rules();
4714 5318 }
4715 5319
4716 5320 /**
4717 5321 * Show a one-time admin notice when a page builder is detected but the
@@ -4717,8 +5321,14 @@
4717 5321 * Show a one-time admin notice when a page builder is detected but the
4718 5322 * "Default Page Builder" setting has never been explicitly saved.
4719 5323 */
4720 5324 public function display_page_builder_notice() {
5325 + // Only relevant on the plugin's own settings page — avoid repeating
5326 + // this notice across every admin screen.
5327 + if (!isset($_GET['page']) || wp_unslash($_GET['page']) !== self::$page_slug) {
5328 + return;
5329 + }
5330 +
4721 5331 $configured = Metasync::get_option('general')['default_page_builder'] ?? '';
4722 5332
4723 5333 // Setting already saved — nothing to warn about
4724 5334 if (!empty($configured)) {
@@ -4731,9 +5341,9 @@
4731 5341 return;
4732 5342 }
4733 5343
4734 5344 // Handle dismiss action
4735 - if (isset($_GET['metasync_dismiss_builder_notice']) && wp_verify_nonce($_GET['_wpnonce'] ?? '', 'metasync_dismiss_builder')) {
5345 + if (isset($_GET['metasync_dismiss_builder_notice'], $_GET['_wpnonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'metasync_dismiss_builder')) {
4736 5346 update_user_meta(get_current_user_id(), 'metasync_builder_notice_dismissed', '1');
4737 5347 return;
4738 5348 }
4739 5349
@@ -4828,9 +5438,10 @@
4828 5438 * Saves the CPU load threshold and returns statistics
4829 5439 */
4830 5440 public function ajax_save_performance_settings() {
4831 5441 # Check nonce for security and return early if invalid
4832 - if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce($_POST['meta_sync_nonce'], 'meta_sync_general_setting_nonce')) {
5442 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5443 + if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce(wp_unslash($_POST['meta_sync_nonce']), 'meta_sync_general_setting_nonce')) {
4833 5444 wp_send_json_error(array('message' => 'Invalid nonce'));
4834 5445 return;
4835 5446 }
4836 5447
@@ -4898,9 +5509,9 @@
4898 5509 if (!wp_next_scheduled('metasync_cleanup_transients')) {
4899 5510 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_cleanup_transients');
4900 5511
4901 5512 if (!$scheduled) {
4902 - error_log('MetaSync: Failed to schedule transient cleanup cron job');
5513 + error_log('MetaSync: Failed to schedule transient cleanup cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
4903 5514 }
4904 5515 }
4905 5516 }
4906 5517
@@ -4911,9 +5522,9 @@
4911 5522 {
4912 5523 $timestamp = wp_next_scheduled('metasync_cleanup_transients');
4913 5524 if ($timestamp) {
4914 5525 wp_unschedule_event($timestamp, 'metasync_cleanup_transients');
4915 - error_log('MetaSync: Transient cleanup cron job unscheduled');
5526 + error_log('MetaSync: Transient cleanup cron job unscheduled'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- unreachable in production
4916 5527 }
4917 5528 }
4918 5529
4919 5530 /**
@@ -4935,12 +5546,10 @@
4935 5546 {
4936 5547 if (!wp_next_scheduled('metasync_hidden_post_check')) {
4937 5548 $scheduled = wp_schedule_event(time(), 'metasync_weekly', 'metasync_hidden_post_check');
4938 5549
4939 - if ($scheduled) {
4940 - error_log('MetaSync: Hidden post manager cron job scheduled successfully (runs every 7 days)');
4941 - } else {
4942 - error_log('MetaSync: Failed to schedule hidden post manager cron job');
5550 + if (!$scheduled) {
5551 + error_log('MetaSync: Failed to schedule hidden post manager cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
4943 5552 }
4944 5553 }
4945 5554 }
4946 5555
@@ -4951,12 +5560,10 @@
4951 5560 public function maybe_schedule_otto_recheck_404_cron()
4952 5561 {
4953 5562 if (!wp_next_scheduled('metasync_otto_recheck_404_exclusions')) {
4954 5563 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_otto_recheck_404_exclusions');
4955 - if ($scheduled) {
4956 - error_log('MetaSync: OTTO 404 recheck cron job scheduled successfully (runs daily)');
4957 - } else {
4958 - error_log('MetaSync: Failed to schedule OTTO 404 recheck cron job');
5564 + if (!$scheduled) {
5565 + error_log('MetaSync: Failed to schedule OTTO 404 recheck cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
4959 5566 }
4960 5567 }
4961 5568 }
4962 5569
@@ -5029,13 +5636,13 @@
5029 5636
5030 5637 try {
5031 5638 // 1. Post revisions
5032 5639 if (!empty($settings['clean_post_revisions'])) {
5033 - $stats['post_revisions'] = (int) $wpdb->query(
5640 + $stats['post_revisions'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5034 5641 "DELETE FROM {$wpdb->posts} WHERE post_type = 'revision'"
5035 5642 );
5036 5643 // Remove postmeta left behind by deleted revisions
5037 - $wpdb->query(
5644 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5038 5645 "DELETE pm FROM {$wpdb->postmeta} pm
5039 5646 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5040 5647 WHERE p.ID IS NULL"
5041 5648 );
@@ -5043,20 +5650,19 @@
5043 5650
5044 5651 // 2. Trashed posts + their postmeta
5045 5652 if (!empty($settings['clean_trashed_posts'])) {
5046 5653 // Collect IDs first to cleanly remove postmeta
5047 - $trashed_ids = $wpdb->get_col(
5654 + $trashed_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5048 5655 "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'trash'"
5049 5656 );
5050 5657 if (!empty($trashed_ids)) {
5051 - $placeholders = implode(',', array_fill(0, count($trashed_ids), '%d'));
5052 - $wpdb->query(
5658 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5053 5659 $wpdb->prepare(
5054 - "DELETE FROM {$wpdb->postmeta} WHERE post_id IN ($placeholders)",
5660 + 'DELETE FROM ' . $wpdb->postmeta . ' WHERE post_id IN (' . implode(',', array_fill(0, count($trashed_ids), '%d')) . ')',
5055 5661 $trashed_ids
5056 5662 )
5057 5663 );
5058 - $stats['trashed_posts'] = (int) $wpdb->query(
5664 + $stats['trashed_posts'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5059 5665 "DELETE FROM {$wpdb->posts} WHERE post_status = 'trash'"
5060 5666 );
5061 5667 } else {
5062 5668 $stats['trashed_posts'] = 0;
@@ -5064,9 +5670,9 @@
5064 5670 }
5065 5671
5066 5672 // 3. Trashed comments
5067 5673 if (!empty($settings['clean_trashed_comments'])) {
5068 - $stats['trashed_comments'] = (int) $wpdb->query(
5674 + $stats['trashed_comments'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5069 5675 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'trash'"
5070 5676 );
5071 5677 }
5072 5678
@@ -5071,9 +5677,9 @@
5071 5677 }
5072 5678
5073 5679 // 4. Spam comments
5074 5680 if (!empty($settings['clean_spam_comments'])) {
5075 - $stats['spam_comments'] = (int) $wpdb->query(
5681 + $stats['spam_comments'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5076 5682 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'spam'"
5077 5683 );
5078 5684 }
5079 5685
@@ -5079,15 +5685,15 @@
5079 5685
5080 5686 // 5. Expired transients — direct SQL, no cache flush
5081 5687 if (!empty($settings['clean_expired_transients'])) {
5082 5688 // Delete timeout rows that have already expired
5083 - $wpdb->query(
5689 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5084 5690 "DELETE FROM {$wpdb->options}
5085 5691 WHERE option_name LIKE '\_transient\_timeout\_%'
5086 5692 AND option_value + 0 < UNIX_TIMESTAMP()"
5087 5693 );
5088 5694 // Delete value rows whose timeout row no longer exists
5089 - $stats['expired_transients'] = (int) $wpdb->query(
5695 + $stats['expired_transients'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5090 5696 "DELETE o FROM {$wpdb->options} o
5091 5697 LEFT JOIN {$wpdb->options} t
5092 5698 ON t.option_name = CONCAT('_transient_timeout_', SUBSTRING(o.option_name, 12))
5093 5699 WHERE o.option_name LIKE '\_transient\_%'
@@ -5097,9 +5703,9 @@
5097 5703 }
5098 5704
5099 5705 // 6. Orphaned postmeta (post_id references a post that no longer exists)
5100 5706 if (!empty($settings['clean_orphaned_postmeta'])) {
5101 - $stats['orphaned_postmeta'] = (int) $wpdb->query(
5707 + $stats['orphaned_postmeta'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5102 5708 "DELETE pm FROM {$wpdb->postmeta} pm
5103 5709 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5104 5710 WHERE p.ID IS NULL"
5105 5711 );
@@ -5111,12 +5717,10 @@
5111 5717 $settings['last_run_at'] = time();
5112 5718 $settings['last_run_stats'] = $stats;
5113 5719 update_option('metasync_db_cleanup_settings', $settings);
5114 5720
5115 - error_log('MetaSync: DB cleanup completed — ' . json_encode($stats));
5116 -
5117 5721 } catch (Exception $e) {
5118 - error_log('MetaSync: DB cleanup failed — ' . $e->getMessage());
5722 + error_log('MetaSync: DB cleanup failed — ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5119 5723 }
5120 5724 }
5121 5725
5122 5726 /**
@@ -5333,10 +5937,11 @@
5333 5937 /**
5334 5938 * AJAX: Save DB cleanup settings and reschedule cron accordingly.
5335 5939 */
5336 5940 public function ajax_save_db_cleanup_settings() {
5941 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5337 5942 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5338 - !wp_verify_nonce($_POST['db_cleanup_settings_nonce'], 'metasync_db_cleanup_settings_nonce')) {
5943 + !wp_verify_nonce(wp_unslash($_POST['db_cleanup_settings_nonce']), 'metasync_db_cleanup_settings_nonce')) {
5339 5944 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
5340 5945 return;
5341 5946 }
5342 5947
@@ -5393,10 +5998,11 @@
5393 5998 * AJAX: Manually trigger the DB cleanup and return stats for the UI.
5394 5999 * Persists current form state first so unsaved checkbox changes are respected.
5395 6000 */
5396 6001 public function ajax_run_db_cleanup() {
6002 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5397 6003 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5398 - !wp_verify_nonce($_POST['db_cleanup_settings_nonce'], 'metasync_db_cleanup_settings_nonce')) {
6004 + !wp_verify_nonce(wp_unslash($_POST['db_cleanup_settings_nonce']), 'metasync_db_cleanup_settings_nonce')) {
5399 6005 wp_send_json_error(array('message' => 'Invalid security token.'));
5400 6006 return;
5401 6007 }
5402 6008
@@ -5472,35 +6078,8 @@
5472 6078 ));
5473 6079 }
5474 6080
5475 6081 /**
5476 - * Control plugin auto-updates based on user setting
5477 - *
5478 - * @param bool $update Whether to update
5479 - * @param object $item Update offer
5480 - * @return bool Whether to allow auto-update
5481 - */
5482 - public function control_plugin_auto_updates($update, $item)
5483 - {
5484 - # Check if the item object has the slug property
5485 - if (!isset($item->slug)) {
5486 - return $update;
5487 - }
5488 -
5489 - // Check if this is our plugin
5490 - if ($item->slug === 'metasync') {
5491 - $general_settings = Metasync::get_option('general') ?? [];
5492 - $enable_auto_updates = $general_settings['enable_auto_updates'] ?? false;
5493 -
5494 - // Return the user's preference (true = allow auto-updates, false = prevent)
5495 - return $enable_auto_updates === 'true' || $enable_auto_updates === true;
5496 - }
5497 -
5498 - // For other plugins, don't interfere with their auto-update settings
5499 - return $update;
5500 - }
5501 -
5502 - /**
5503 6082 * AJAX handler to add excluded URL for OTTO
5504 6083 */
5505 6084 public function ajax_otto_add_excluded_url()
5506 6085 {
@@ -5646,8 +6225,9 @@
5646 6225 // If page has raw HTML or custom CSS from conversion, show badge
5647 6226 if ($has_raw_html || !empty($has_custom_css)) {
5648 6227 $label = $this->get_html_source_label();
5649 6228 $tooltip = sprintf(
6229 + /* translators: %s: HTML source label. */
5650 6230 __('This page was created using %s HTML-to-Builder converter', 'metasync'),
5651 6231 $label
5652 6232 );
5653 6233
@@ -5686,8 +6266,16 @@
5686 6266 if (!$post || !in_array($post->post_type, array('post', 'page'))) {
5687 6267 return;
5688 6268 }
5689 6269
6270 + // Don't show the "HTML-to-Builder converter" banner on LPS-synced / custom-HTML
6271 + // pages: those are raw-HTML store-and-serve pages, NOT actually converted to a
6272 + // page builder, so the banner mislabels them. It still shows for pages genuinely
6273 + // produced by the converter.
6274 + if (function_exists('metasync_is_custom_or_lps_page') && metasync_is_custom_or_lps_page($post->ID)) {
6275 + return;
6276 + }
6277 +
5690 6278 $has_raw_html = get_post_meta($post->ID, '_metasync_raw_html_enabled', true);
5691 6279 $has_custom_css = get_post_meta($post->ID, '_metasync_custom_css', true);
5692 6280
5693 6281 if ($has_raw_html || !empty($has_custom_css)) {
@@ -5692,8 +6280,9 @@
5692 6280
5693 6281 if ($has_raw_html || !empty($has_custom_css)) {
5694 6282 $label = $this->get_html_source_label();
5695 6283 $message = sprintf(
6284 + /* translators: %s: HTML source label. */
5696 6285 __('This page was created using %s HTML-to-Builder converter. The design is preserved with custom CSS and inline styles.', 'metasync'),
5697 6286 '<strong>' . esc_html($label) . '</strong>'
5698 6287 );
5699 6288
@@ -5707,9 +6296,9 @@
5707 6296 <p class="metasync-editor-message">%s</p>
5708 6297 </div>
5709 6298 </div>',
5710 6299 esc_html($label),
5711 - $message
6300 + wp_kses($message, array('strong' => array()))
5712 6301 );
5713 6302 }
5714 6303 }
5715 6304
@@ -5732,9 +6321,9 @@
5732 6321 ?>
5733 6322 <fieldset class="inline-edit-col-left metasync-quick-edit-source">
5734 6323 <div class="inline-edit-col">
5735 6324 <label>
5736 - <span class="title"><?php _e('Source', 'metasync'); ?></span>
6325 + <span class="title"><?php esc_html_e('Source', 'metasync'); ?></span>
5737 6326 <span class="metasync-quick-edit-badge-container"></span>
5738 6327 </label>
5739 6328 </div>
5740 6329 </fieldset>
@@ -5746,8 +6335,9 @@
5746 6335 */
5747 6336 public function add_html_pages_dashboard_widget()
5748 6337 {
5749 6338 $label = $this->get_html_source_label();
6339 + /* translators: %s: HTML source label. */
5750 6340 $widget_title = sprintf(__('%s Pages', 'metasync'), $label);
5751 6341
5752 6342 wp_add_dashboard_widget(
5753 6343 'metasync_html_pages_widget',
@@ -5803,8 +6393,14 @@
5803 6393 * @return void Sends JSON response and exits
5804 6394 */
5805 6395 public function ajax_send_giapi()
5806 6396 {
6397 + check_ajax_referer('metasync_nonce', 'nonce');
6398 +
6399 + if (!Metasync::current_user_has_plugin_access()) {
6400 + wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
6401 + }
6402 +
5807 6403 $post_data = metasync_sanitize_input_array($_POST);
5808 6404 if (!isset($post_data['metasync_giapi_url'])) {
5809 6405 return;
5810 6406 }
@@ -5870,8 +6466,14 @@
5870 6466 * @return void Sends JSON response and exits
5871 6467 */
5872 6468 public function ajax_send_bing_indexnow()
5873 6469 {
6470 + check_ajax_referer('metasync_nonce', 'nonce');
6471 +
6472 + if (!Metasync::current_user_has_plugin_access()) {
6473 + wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
6474 + }
6475 +
5874 6476 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
5875 6477 $bing_instant_index = new Metasync_Bing_Instant_Index();
5876 6478 $bing_instant_index->send();
5877 6479 }
@@ -5876,34 +6478,8 @@
5876 6478 $bing_instant_index->send();
5877 6479 }
5878 6480
5879 6481 /**
5880 - * Save instant indexing settings (Google and Bing)
5881 - *
5882 - * @since 2.6.0
5883 - * @return void
5884 - */
5885 - public function save_instant_indexing_settings()
5886 - {
5887 - // Check if this is a settings submission
5888 - if (!isset($_POST['submit'])) {
5889 - return;
5890 - }
5891 -
5892 - // Save post types for Google Instant Indexing auto-submit
5893 - if (isset($_POST['metasync_post_types'])) {
5894 - $post_data = metasync_sanitize_input_array($_POST);
5895 - $post_types = is_array($post_data['metasync_post_types']) ? array_map('sanitize_title', $post_data['metasync_post_types']) : [];
5896 -
5897 - $settings = get_option('metasync_options_instant_indexing', ['post_types' => []]);
5898 - $settings['post_types'] = array_values($post_types);
5899 - update_option('metasync_options_instant_indexing', $settings);
5900 - }
5901 -
5902 - // Note: Bing Instant Indexing settings are saved via AJAX in save_bing_inline_settings_ajax()
5903 - }
5904 -
5905 - /**
5906 6482 * Save Bing instant indexing settings from inline form (Indexation Control page)
5907 6483 *
5908 6484 * @since 2.6.0
5909 6485 * @return bool True on success, false on failure
@@ -5973,12 +6549,24 @@
5973 6549 if (!empty($seo_controls['enable_googleinstantindex']) && $seo_controls['enable_googleinstantindex'] === 'true') {
5974 6550 $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
5975 6551 $post_types = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
5976 6552
5977 - if (in_array($post->post_type, $post_types) && function_exists('google_index_direct')) {
5978 - $service_info = google_index_direct()->get_service_account_info();
5979 - if (!isset($service_info['error'])) {
5980 - google_index_direct()->index_post($post_id, $post->post_type, 'update');
6553 + if (in_array($post->post_type, $post_types)) {
6554 + // save_post is the single owner of auto-submit and also fires
6555 + // on REST-created posts, where nothing else may have loaded
6556 + // the Google Index helpers yet — load them on demand instead
6557 + // of silently skipping.
6558 + if (!function_exists('google_index_direct')) {
6559 + $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
6560 + if (file_exists($google_index_path)) {
6561 + require_once $google_index_path;
6562 + }
6563 + }
6564 + if (function_exists('google_index_direct')) {
6565 + $service_info = google_index_direct()->get_service_account_info();
6566 + if (!isset($service_info['error'])) {
6567 + google_index_direct()->index_post($post_id, $post->post_type, 'update');
6568 + }
5981 6569 }
5982 6570 }
5983 6571 }
5984 6572