PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | admin/class-metasync-admin.php +832 -369 2.6.10 → 2.7.1 View file →
@@ -33,9 +33,8 @@
33 33 const SECTION_METASYNC = "metasync_settings";
34 34 const SECTION_SEARCHENGINE = "searchengine_settings";
35 35 const SECTION_LOCALSEO = "local_seo";
36 36 const SECTION_CODESNIPPETS = "code_snippets";
37 - const SECTION_OPTIMAL_SETTINGS = "optimal_settings";
38 37 const SECTION_SITE_SETTINGS = "site_settings";
39 38 const SECTION_COMMON_SETTINGS = "common_settings";
40 39 const SECTION_COMMON_META_SETTINGS = "common_meta_settings";
41 40 const SECTION_SOCIAL_META = "social_meta";
@@ -42,9 +41,8 @@
42 41 const SECTION_SEO_CONTROLS = "seo_controls";
43 42 const SECTION_SEO_CONTROLS_ADVANCED = "seo_controls_advanced";
44 43 const SECTION_SEO_CONTROLS_INSTANT_INDEX = "seo_controls_instant_index";
45 44 const SECTION_PLUGIN_VISIBILITY = "plugin_visibility_settings";
46 - const SECTION_BREADCRUMBS = "breadcrumbs_settings";
47 45 const SECTION_LLMS_TXT = "llms_txt_settings";
48 46
49 47 /**
50 48 * The ID of this plugin.
@@ -198,13 +196,18 @@
198 196 $data = Metasync::get_option('general');
199 197
200 198 // Set menu title using the effective title (includes whitelabel company name if available)
201 199 $this->menu_title = $this->get_effective_menu_title();
202 - if(!isset( $data['white_label_plugin_menu_slug'])){
203 - self::$page_slug = "searchatlas";
204 - }else{
205 - self::$page_slug = $data['white_label_plugin_menu_slug']=="" ? "searchatlas":$data['white_label_plugin_menu_slug'];
206 - }
200 + $raw_slug = isset($data['white_label_plugin_menu_slug']) ? $data['white_label_plugin_menu_slug'] : '';
201 + $clean_slug = sanitize_title($raw_slug);
202 + # Self-heal a legacy URL-shaped slug: persist the sanitized value so every
203 + # reader that builds admin links from the stored option gets a valid WP menu slug.
204 + if ($raw_slug !== '' && $clean_slug !== $raw_slug) {
205 + $options = Metasync::get_option();
206 + $options['general']['white_label_plugin_menu_slug'] = $clean_slug;
207 + Metasync::set_option($options);
208 + }
209 + self::$page_slug = $clean_slug === '' ? 'searchatlas' : $clean_slug;
207 210
208 211 add_action('admin_menu', array($this, 'add_plugin_settings_page'));
209 212 add_action('admin_menu', array($this, 'add_import_external_data_page'));
210 213 add_action('admin_init', array($this, 'settings_page_init'));
@@ -209,9 +212,8 @@
209 212 add_action('admin_menu', array($this, 'add_import_external_data_page'));
210 213 add_action('admin_init', array($this, 'settings_page_init'));
211 214 add_filter('all_plugins', array($this,'metasync_plugin_white_label'));
212 215 add_filter( 'plugin_row_meta',array($this,'metasync_view_detials_url'),10,3);
213 - add_filter('site_transient_update_plugins', array($this, 'inject_whitelabel_icon_into_update_transient'));
214 216
215 217 // Display transient error/success messages for redirections
216 218 add_action('admin_notices', array($this, 'display_redirection_messages'));
217 219
@@ -260,8 +262,9 @@
260 262 add_action('metasync_api_key_changed', array('Metasync_Admin_Navigation', 'invalidate_admin_bar_status_cache'), 10, 0);
261 263
262 264 add_action('admin_init', array($this, 'initialize_cookie'));
263 265 add_action('admin_init', array($this, 'maybe_redirect_to_wizard'));
266 + add_action('admin_init', array($this, 'redirect_legacy_instant_indexing_page'));
264 267
265 268 // Add admin_post hooks for form submissions (WordPress standard way - no output buffering needed)
266 269 add_action('admin_post_metasync_clear_all_cache_plugins', array($this, 'handle_clear_all_cache_plugins'));
267 270 add_action('admin_post_metasync_clear_otto_cache_all', array($this, 'handle_clear_otto_cache_all'));
@@ -279,10 +282,16 @@
279 282
280 283 // Add AJAX for saving execution settings
281 284 add_action( 'wp_ajax_metasync_save_execution_settings', array($this, 'ajax_save_execution_settings') );
282 285
286 + // Add AJAX for saving the Headless Mode demo controls on the Advanced tab
287 + add_action( 'wp_ajax_metasync_save_headless_settings', array(Metasync_Settings_Registration::instance(), 'ajax_save_headless_settings') );
288 +
283 289 // Add AJAX for saving hosting cache settings
284 290 add_action( 'wp_ajax_metasync_save_hosting_cache_settings', array($this, 'ajax_save_hosting_cache_settings') );
291 +
292 + // Add AJAX for saving OTTO Cache TTL
293 + add_action('wp_ajax_metasync_save_otto_cache_ttl', array($this, 'ajax_save_otto_cache_ttl'));
285 294 add_action( 'wp_ajax_metasync_save_object_cache_settings', array($this, 'ajax_save_object_cache_settings') );
286 295
287 296 // Add AJAX for saving edge cache / CDN settings
288 297 add_action( 'wp_ajax_metasync_save_edge_cache_settings', array('Metasync_Edge_Cache_Settings', 'ajax_save') );
@@ -307,8 +316,9 @@
307 316 add_action('wp_ajax_metasync_get_default_robots', array($this, 'ajax_get_default_robots'));
308 317 add_action('wp_ajax_metasync_preview_robots_backup', array($this, 'ajax_preview_robots_backup'));
309 318 add_action('wp_ajax_metasync_delete_robots_backup', array($this, 'ajax_delete_robots_backup'));
310 319 add_action('wp_ajax_metasync_restore_robots_backup', array($this, 'ajax_restore_robots_backup'));
320 + add_action('wp_ajax_metasync_get_robots_backups', array($this, 'ajax_get_robots_backups'));
311 321
312 322 // Add AJAX handlers for host blocking test
313 323 add_action('wp_ajax_metasync_test_host_blocking_get', array($this, 'ajax_test_host_blocking_get'));
314 324 add_action('wp_ajax_metasync_test_host_blocking_post', array($this, 'ajax_test_host_blocking_post'));
@@ -362,10 +372,18 @@
362 372 add_action('wp_ajax_metasync_batch_progress', array($this, 'ajax_batch_progress'));
363 373 add_action('wp_ajax_metasync_bulk_optimize_selected', array($this, 'ajax_bulk_optimize_selected'));
364 374 add_action('wp_ajax_metasync_bulk_unoptimize_selected', array($this, 'ajax_bulk_unoptimize_selected'));
365 375 add_action('wp_ajax_metasync_process_batch_tick', array($this, 'ajax_process_batch_tick'));
376 + add_action('wp_ajax_metasync_delete_orphaned_image', array($this, 'ajax_delete_orphaned_image'));
366 377 add_action('metasync_media_batch_optimize_cron', array($this, 'handle_media_batch_cron'));
367 378
379 + # SEO Restore (Rollback) AJAX & Cron handlers
380 + add_action('wp_ajax_metasync_seo_restore_start', array($this, 'ajax_seo_restore_start'));
381 + add_action('wp_ajax_metasync_seo_restore_cancel', array($this, 'ajax_seo_restore_cancel'));
382 + add_action('wp_ajax_metasync_seo_restore_progress', array($this, 'ajax_seo_restore_progress'));
383 + add_action('wp_ajax_metasync_seo_restore_process_tick', array($this, 'ajax_seo_restore_process_tick'));
384 + add_action('metasync_seo_restore_cron', array($this, 'handle_seo_restore_cron'));
385 +
368 386 # Add AJAX handlers for Google Instant Indexing
369 387 add_action('wp_ajax_metasync_send_giapi', array($this, 'ajax_send_giapi'));
370 388
371 389 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
@@ -458,9 +476,11 @@
458 476
459 477 $data= Metasync::get_option('general');
460 478
461 479 # Get white label menu slug
462 - $menu_slug = empty($data['white_label_plugin_menu_slug']) ? self::$page_slug : $data['white_label_plugin_menu_slug'];
480 + # Sanitize so a URL-shaped legacy value still yields a valid WP menu slug
481 + $menu_slug = empty($data['white_label_plugin_menu_slug']) ? self::$page_slug : sanitize_title($data['white_label_plugin_menu_slug']);
482 + $menu_slug = $menu_slug === '' ? self::$page_slug : $menu_slug;
463 483
464 484 ?>
465 485 <style>
466 486 #toplevel_page_<?php echo esc_attr(str_replace(' ', '-',$menu_slug)); ?> .wp-menu-image.dashicons-before img {
@@ -477,23 +497,129 @@
477 497 <?php
478 498 }
479 499
480 500 public function metasync_fouc_prevention_style() {
481 - if ( ! isset( $_GET['page'] ) || strpos( $_GET['page'], self::$page_slug ) !== 0 ) {
501 + if ( ! isset( $_GET['page'] ) || strpos( sanitize_key(wp_unslash($_GET['page'])), self::$page_slug ) !== 0 ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check for an inline style
482 502 return;
483 503 }
484 504 ?>
485 - <style>.metasync-dashboard-wrap { opacity: 0; transition: opacity 0.15s ease-in; }</style>
505 + <style>
506 + .metasync-dashboard-wrap { opacity: 0; transition: opacity 0.15s ease-in; }
507 + /*
508 + * WP prints admin notices at the top of #wpbody-content, then core's
509 + * common.js relocates them next to .wp-header-end inside our wrap on
510 + * DOM-ready. Keep them out of the flow until that move so they don't
511 + * flash above the dashboard header and shove the page down.
512 + * Once relocated they're no longer a direct child here, so this rule
513 + * stops matching and they fade in with the wrap. We mirror core's
514 + * relocation set exactly (.notice/.updated/.error, minus .inline and
515 + * .below-h2); .update-nag is left alone since core never moves it.
516 + */
517 + #wpbody-content > .notice:not(.inline):not(.below-h2),
518 + #wpbody-content > .updated:not(.inline):not(.below-h2),
519 + #wpbody-content > .error:not(.inline):not(.below-h2) { display: none !important; }
520 + </style>
486 521 <?php
487 522 }
488 523
489 524 public function suppress_notices_on_wizard_page() {
490 - if ( isset( $_GET['page'] ) && strpos( $_GET['page'], '-setup-wizard' ) !== false ) {
525 + if ( ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
526 + return;
527 + }
528 +
529 + $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
530 +
531 + // Setup wizard: strip every notice for a fully focused, distraction-free screen.
532 + if ( strpos( $page, '-setup-wizard' ) !== false ) {
491 533 remove_all_actions( 'admin_notices' );
492 534 remove_all_actions( 'all_admin_notices' );
535 + return;
493 536 }
537 +
538 + // Other Search Atlas plugin pages: strip third-party notices but keep our own.
539 + if ( strpos( $page, self::$page_slug ) === 0 ) {
540 + $this->remove_third_party_admin_notices();
541 + }
494 542 }
495 543
544 + /**
545 + * Remove admin-notice callbacks that don't belong to this plugin.
546 + *
547 + * Third-party plugins (e.g. Rank Math, Yoast) register their banners on the
548 + * global admin_notices / all_admin_notices actions, which WordPress fires on
549 + * every admin screen — so their notices leak onto our settings pages. We walk
550 + * the registered callbacks and drop any whose code lives outside this plugin's
551 + * directory, leaving every one of our own notices intact.
552 + */
553 + private function remove_third_party_admin_notices() {
554 + global $wp_filter;
555 +
556 + foreach ( array( 'admin_notices', 'all_admin_notices' ) as $hook ) {
557 + if ( empty( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof WP_Hook ) ) {
558 + continue;
559 + }
560 +
561 + // Collect first, then remove — avoids mutating the callbacks array mid-walk.
562 + $to_remove = array();
563 + foreach ( $wp_filter[ $hook ]->callbacks as $priority => $callbacks ) {
564 + foreach ( $callbacks as $callback ) {
565 + if ( ! $this->is_own_admin_notice_callback( $callback['function'] ) ) {
566 + $to_remove[] = array( $callback['function'], $priority );
567 + }
568 + }
569 + }
570 +
571 + foreach ( $to_remove as $entry ) {
572 + remove_action( $hook, $entry[0], $entry[1] );
573 + }
574 + }
575 + }
576 +
577 + /**
578 + * Determine whether an admin-notice callback is defined by this plugin.
579 + *
580 + * Ownership is decided by where the callback's code physically lives, not by
581 + * class name — our notice owners use inconsistent prefixes (Metasync_Admin,
582 + * Google_Index_Admin, ConfigControllerMetaSync), and a white-label install can
583 + * rename the plugin folder. Resolving the declaring file and checking it sits
584 + * inside this plugin's directory is robust against all of that. If a callback
585 + * can't be introspected, we keep it rather than risk hiding a legitimate notice.
586 + *
587 + * @param string|array|Closure $function The registered callback.
588 + * @return bool True when the callback's code lives inside this plugin.
589 + */
590 + private function is_own_admin_notice_callback( $function ) {
591 + // Plugin root: this file lives in <plugin>/admin, so its parent is the root.
592 + $plugin_dir = wp_normalize_path( dirname( __DIR__ ) ) . '/';
593 +
594 + try {
595 + if ( is_array( $function ) && isset( $function[0], $function[1] ) ) {
596 + $class = is_object( $function[0] ) ? get_class( $function[0] ) : $function[0];
597 + $ref = new ReflectionMethod( $class, $function[1] );
598 + } elseif ( $function instanceof Closure ) {
599 + $ref = new ReflectionFunction( $function );
600 + } elseif ( is_string( $function ) && strpos( $function, '::' ) !== false ) {
601 + list( $class, $method ) = explode( '::', $function, 2 );
602 + $ref = new ReflectionMethod( $class, $method );
603 + } elseif ( is_string( $function ) ) {
604 + $ref = new ReflectionFunction( $function );
605 + } else {
606 + return false;
607 + }
608 + } catch ( \Throwable $e ) {
609 + // Unintrospectable callback — keep it; never hide a notice we can't classify.
610 + return true;
611 + }
612 +
613 + $file = $ref->getFileName();
614 + if ( ! $file ) {
615 + // Internal/built-in callback (no source file) — not ours.
616 + return false;
617 + }
618 +
619 + return strpos( wp_normalize_path( $file ), $plugin_dir ) === 0;
620 + }
621 +
496 622 #---------fixes issue : #95 ----------
497 623 #This function is to redirect in case client changes slug on fresh install
498 624 #It is called by the add_option hook
499 625
@@ -519,9 +645,9 @@
519 645
520 646 #Redirect url to the new slug
521 647 $redirect_url = admin_url('admin.php?page=' . $current_slug);
522 648
523 - wp_redirect($redirect_url);
649 + wp_safe_redirect($redirect_url);
524 650 exit;
525 651 }
526 652 }
527 653
@@ -573,13 +699,13 @@
573 699 * @since 1.0.0
574 700 */
575 701 public function maybe_redirect_to_wizard() {
576 702 // Only redirect if wizard should be shown
577 - if (get_option('metasync_show_wizard') && !isset($_GET['page'])) {
703 + if (get_option('metasync_show_wizard') && !isset($_GET['page'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- isset() routing check for a redirect to a fixed admin URL
578 704 delete_option('metasync_show_wizard');
579 705
580 706 // Don't redirect during AJAX, cron, or bulk activation
581 - if (wp_doing_ajax() || wp_doing_cron() || isset($_GET['activate-multi'])) {
707 + if (wp_doing_ajax() || wp_doing_cron() || isset($_GET['activate-multi'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- isset() routing check for a redirect to a fixed admin URL
582 708 return;
583 709 }
584 710
585 711 // Only redirect if user has access to the plugin
@@ -591,11 +717,56 @@
591 717 exit;
592 718 }
593 719 }
594 720
721 + /**
722 + * Redirect the removed standalone Instant Indexing page to Indexation Control.
723 + *
724 + * The menu entry is gone, but bookmarks and external links can still target
725 + * the old URL. Resolve the current white-label slug rather than assuming
726 + * "searchatlas".
727 + */
728 + public function redirect_legacy_instant_indexing_page() {
729 + if (wp_doing_ajax() || wp_doing_cron() || !is_admin() || !isset($_GET['page'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
730 + return;
731 + }
732 +
733 + $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
734 + $legacy_page = self::$page_slug . '-instant-index';
735 + if ($page !== $legacy_page || !Metasync::current_user_has_plugin_access()) {
736 + return;
737 + }
738 +
739 + // Indexation Control is registered only when access control keeps it
740 + // visible. Redirecting to it blindly would dead-end on an
741 + // unregistered slug (wp_die "Invalid plugin page") for users whose
742 + // white-label config hides that page — the exact configs where the
743 + // removed standalone page was the feature's only UI. The bare plugin
744 + // slug is the floor: its top-level callback resolves to the first
745 + // reachable page, so it is always a safe target.
746 + if (Metasync_Access_Control::user_can_access('hide_indexation_control')) {
747 + $target = admin_url('admin.php?page=' . self::$page_slug . '-seo-controls');
748 + $query = [];
749 + foreach (['tab', 'subtab'] as $key) {
750 + if (isset($_GET[$key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
751 + $query[$key] = sanitize_key(wp_unslash($_GET[$key])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
752 + }
753 + }
754 + if (!empty($query)) {
755 + $target = add_query_arg($query, $target);
756 + }
757 + } else {
758 + $target = admin_url('admin.php?page=' . self::$page_slug);
759 + }
760 +
761 + wp_safe_redirect($target);
762 + exit;
763 + }
764 +
595 765 public function metasync_display_error_log() {
596 766 Metasync_Debug_Manager::instance()->metasync_display_error_log($this);
597 767 }
768 +
598 769 public function metasync_update_wp_config() {
599 770 Metasync_Debug_Manager::instance()->metasync_update_wp_config();
600 771 }
601 772
@@ -638,9 +809,9 @@
638 809 ]);
639 810 $_COOKIE['metasync_previous_slug'] = $new_slug;
640 811 // Redirect to the new slug
641 812 $redirect_url = admin_url('admin.php?page=' . $old_slug);
642 - wp_redirect($redirect_url);
813 + wp_safe_redirect($redirect_url);
643 814 exit;
644 815 }else{
645 816 self::$page_slug = Metasync::get_option('general')['white_label_plugin_menu_slug']=="" ? "searchatlas":Metasync::get_option('general')['white_label_plugin_menu_slug'];
646 817 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
@@ -646,9 +817,9 @@
646 817 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
647 818
648 819 #add redirection for when the old slug is not defined
649 820 #this fixes the redirect issue #
650 - wp_redirect($redirect_url);
821 + wp_safe_redirect($redirect_url);
651 822 exit;
652 823 }
653 824 }
654 825 public function metasync_view_detials_url( $plugin_meta, $plugin_file, $plugin_data ) {
@@ -659,9 +830,10 @@
659 830 if (strpos($meta, 'open-plugin-details-modal') !== false) {
660 831 $meta = sprintf(
661 832 '<a href="%s" class="thickbox open-plugin-details-modal" aria-label="%s" data-title="%s">%s</a>',
662 833 add_query_arg('TB_iframe', 'true', $plugin_uri),
663 - esc_attr(sprintf(esc_html__('More information about %s'), $plugin_data['Name'])),
834 + /* translators: %s: plugin name. */
835 + esc_attr(sprintf(esc_html__('More information about %s', 'metasync'), $plugin_data['Name'])),
664 836 esc_attr($plugin_data['Name']),
665 837 esc_html__('View details', 'metasync')
666 838 );
667 839 break; // Exit loop after replacing the link
@@ -709,53 +881,8 @@
709 881 return $all_plugins;
710 882 }
711 883
712 884 /**
713 - * Inject the whitelabel icon into the update_plugins transient so that
714 - * /wp-admin/update-core.php (Dashboard → Updates) shows the WL icon
715 - * instead of the SearchAtlas icon returned by the update API.
716 - *
717 - * @param object $transient The site transient object.
718 - * @return object
719 - */
720 - public function inject_whitelabel_icon_into_update_transient($transient) {
721 - if (empty($transient) || !is_object($transient)) {
722 - return $transient;
723 - }
724 -
725 - $general = Metasync::get_option('general');
726 - if (!is_array($general)) {
727 - return $transient;
728 - }
729 -
730 - $icon_url = $general['white_label_plugin_menu_icon'] ?? '';
731 - if (empty($icon_url)) {
732 - return $transient;
733 - }
734 -
735 - $this_plugin = plugin_basename(dirname(__DIR__) . '/metasync.php');
736 -
737 - // Inject into pending updates list
738 - if (!empty($transient->response) && isset($transient->response[$this_plugin])) {
739 - $transient->response[$this_plugin]->icons = [
740 - '1x' => $icon_url,
741 - '2x' => $icon_url,
742 - ];
743 - }
744 -
745 - // Also inject into the "no update needed" list so the icon appears
746 - // on the updates screen even when the plugin is up-to-date
747 - if (!empty($transient->no_update) && isset($transient->no_update[$this_plugin])) {
748 - $transient->no_update[$this_plugin]->icons = [
749 - '1x' => $icon_url,
750 - '2x' => $icon_url,
751 - ];
752 - }
753 -
754 - return $transient;
755 - }
756 -
757 - /**
758 885 * Register the stylesheets for the admin area.
759 886 *
760 887 * @since 1.0.0
761 888 */
@@ -760,9 +887,9 @@
760 887 * @since 1.0.0
761 888 */
762 889 public function enqueue_styles()
763 890 {
764 - $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
891 + $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
765 892
766 893 if (strpos($current_page, self::$page_slug) === 0) {
767 894 wp_enqueue_style(
768 895 $this->plugin_name,
@@ -791,9 +918,9 @@
791 918 );
792 919 }
793 920
794 921 // Enqueue wizard CSS if on wizard page
795 - if (isset($_GET['page']) && strpos($_GET['page'], '-setup-wizard') !== false) {
922 + if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-setup-wizard') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
796 923 wp_enqueue_style(
797 924 $this->plugin_name . '-setup-wizard',
798 925 plugin_dir_url(__FILE__) . 'css/metasync-setup-wizard.css',
799 926 array($this->plugin_name . '-dashboard'),
@@ -802,9 +929,9 @@
802 929 );
803 930 }
804 931
805 932 // Enqueue SEO Health CSS if on the SEO Health page
806 - if (isset($_GET['page']) && strpos($_GET['page'], '-seo-health') !== false) {
933 + if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-seo-health') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
807 934 wp_enqueue_style(
808 935 $this->plugin_name . '-seo-health',
809 936 plugin_dir_url(__FILE__) . 'css/metasync-seo-health.css',
810 937 array($this->plugin_name . '-dashboard'),
@@ -821,9 +948,9 @@
821 948 */
822 949 public function enqueue_scripts()
823 950 {
824 951 // --- Phase 5 (#887): Extracted inline JS files ---
825 - $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
952 + $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick scripts
826 953 $plugin_root_url = plugin_dir_url(dirname(__FILE__));
827 954 $is_metasync_page = ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0);
828 955
829 956 if ($is_metasync_page) {
@@ -853,10 +980,49 @@
853 980 array('jquery'),
854 981 $this->version,
855 982 true
856 983 );
984 +
985 + // Keeps core's admin-menu pinning in step with pages whose height
986 + // changes after load. Depends on 'common' so core has bound its
987 + // own handlers before this runs.
988 + wp_enqueue_script(
989 + $this->plugin_name . '-admin-menu-height',
990 + plugin_dir_url(__FILE__) . 'js/metasync-admin-menu-height.js',
991 + array('jquery', 'common'),
992 + $this->version,
993 + true
994 + );
857 995 }
858 996
997 + $per_page_screens = [
998 + self::$page_slug . '-redirections',
999 + self::$page_slug . '-404-monitor',
1000 + self::$page_slug . '-sync-log',
1001 + self::$page_slug . '-media-optimization',
1002 + ];
1003 +
1004 + if (in_array($current_page, $per_page_screens, true)) {
1005 + // Results-per-page selectors use this external handler instead of
1006 + // an inline event attribute so changing a page size always reloads
1007 + // the current admin page.
1008 + wp_enqueue_script(
1009 + $this->plugin_name . '-per-page',
1010 + plugin_dir_url(__FILE__) . 'js/metasync-per-page.js',
1011 + array(),
1012 + $this->version,
1013 + true
1014 + );
1015 + // Selector navigation is a GET, so the per-page nonce rides along
1016 + // as a query parameter and gates the user_meta persist in
1017 + // Metasync_Per_Page_Helper::resolve().
1018 + wp_localize_script(
1019 + $this->plugin_name . '-per-page',
1020 + 'metasyncPerPage',
1021 + array('nonces' => Metasync_Per_Page_Helper::page_nonces())
1022 + );
1023 + }
1024 +
859 1025 // Dashboard iframe height (only on dashboard page)
860 1026 if ($current_page === self::$page_slug . '-dashboard' || $current_page === self::$page_slug) {
861 1027 wp_enqueue_script(
862 1028 $this->plugin_name . '-iframe',
@@ -905,12 +1071,12 @@
905 1071 true
906 1072 );
907 1073 // Pass the active tab from server (handles POST redirect)
908 1074 $sitemap_active_tab = 'general';
909 - if (isset($_GET['tab']) && in_array($_GET['tab'], ['general', 'news', 'video'], true)) {
910 - $sitemap_active_tab = sanitize_text_field(wp_unslash($_GET['tab']));
911 - } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) {
912 - $sitemap_active_tab = sanitize_text_field(wp_unslash($_POST['redirect_tab']));
1075 + if (isset($_GET['tab']) && in_array($_GET['tab'], ['general', 'news', 'video'], true)) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed tab selection for the UI
1076 + $sitemap_active_tab = sanitize_text_field(wp_unslash($_GET['tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed tab selection for the UI
1077 + } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only, allow-listed tab selection for the UI
1078 + $sitemap_active_tab = sanitize_text_field(wp_unslash($_POST['redirect_tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only, allow-listed tab selection for the UI
913 1079 }
914 1080 wp_localize_script($this->plugin_name . '-sitemap-tabs', 'metasyncSitemapTabs', [
915 1081 'activeTab' => $sitemap_active_tab,
916 1082 ]);
@@ -1041,9 +1207,8 @@
1041 1207 true
1042 1208 );
1043 1209 wp_localize_script($this->plugin_name . '-connect', 'metasyncConnectData', array(
1044 1210 'optionKey' => self::option_key,
1045 - 'storedPassword' => isset($whitelabel_data['settings_password']) ? $whitelabel_data['settings_password'] : '',
1046 1211 'adminPostUrl' => admin_url('admin-post.php'),
1047 1212 'exportNonce' => wp_create_nonce('metasync_export_whitelabel'),
1048 1213 'ajaxUrl' => admin_url('admin-ajax.php'),
1049 1214 'logoutNonceField' => wp_nonce_field('whitelabel_logout_nonce', 'whitelabel_logout_nonce', true, false),
@@ -1131,8 +1296,11 @@
1131 1296 array('jquery'),
1132 1297 $this->version,
1133 1298 true
1134 1299 );
1300 + wp_localize_script($this->plugin_name . '-bing-console', 'metasyncBingConsoleData', array(
1301 + 'nonce' => wp_create_nonce('metasync_nonce'),
1302 + ));
1135 1303 }
1136 1304
1137 1305 // Add redirection form
1138 1306 if ($current_page === self::$page_slug . '-redirections') {
@@ -1236,8 +1404,10 @@
1236 1404 'nonce' => wp_create_nonce('metasync_nonce'),
1237 1405 'sa_connect_nonce' => $sa_connect_nonce,
1238 1406 'reset_auth_nonce' => wp_create_nonce('metasync_reset_auth_nonce'),
1239 1407 'burst_ping_nonce' => wp_create_nonce('metasync_burst_ping'),
1408 + 'clear_otto_cache_nonce' => wp_create_nonce('metasync_clear_otto_cache'),
1409 + 'lglogin_nonce' => wp_create_nonce('metasync_lglogin'),
1240 1410 'heartbeat_state' => $heartbeat_state,
1241 1411 'dashboard_domain' => self::get_effective_dashboard_domain(),
1242 1412 'support_email' => Metasync::SUPPORT_EMAIL,
1243 1413 'documentation_domain' => Metasync::DOCUMENTATION_DOMAIN,
@@ -1301,9 +1471,9 @@
1301 1471 add_action('admin_notices', array($this, 'display_page_builder_notice'));
1302 1472 // Display update warning banner if plugin update is available
1303 1473 add_action('admin_notices', array($this, 'display_update_warning_banner'));
1304 1474 // Enqueue wizard assets if on wizard page
1305 - if (isset($_GET['page']) && strpos($_GET['page'], '-setup-wizard') !== false) {
1475 + if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-setup-wizard') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick scripts
1306 1476 wp_enqueue_script(
1307 1477 $this->plugin_name . '-setup-wizard',
1308 1478 plugin_dir_url(__FILE__) . 'js/metasync-setup-wizard.js',
1309 1479 array('jquery'),
@@ -1327,14 +1497,44 @@
1327 1497 }
1328 1498
1329 1499 /**
1330 1500 * Settings of HeartBeat API for admin area.
1331 - * Set time interval of send request.
1501 + *
1502 + * The only thing the plugin rides the beat for is a periodic admin
1503 + * telemetry ping (the `heartbeat-send` handler in admin/js/metasync-admin.js),
1504 + * so it slows the beat down rather than speeding it up. Two contexts are
1505 + * left on whatever core decided, because core leans on the beat there:
1506 + *
1507 + * - The front end, where the beat drives wp-auth-check — the "your session
1508 + * has expired, log in again" prompt.
1509 + * - The editor screens, where each tick renews the post lock. A lock only
1510 + * lives for wp_check_post_lock_window() seconds (150 by default), so any
1511 + * interval longer than that drops the lock between beats and lets a
1512 + * second editor silently take over a post someone still has open.
1513 + *
1514 + * Core clamps this to 1-3600 seconds in wp-includes/js/heartbeat.js, so an
1515 + * over-long interval is not corrected on our behalf.
1516 + *
1517 + * @param array $settings Heartbeat settings array.
1518 + * @return array Heartbeat settings array.
1332 1519 */
1333 1520 function metasync_heartbeat_settings($settings)
1334 1521 {
1335 - global $heartbeat_frequency;
1336 - $settings['interval'] = 300;
1522 + if (!is_admin()) {
1523 + return $settings;
1524 + }
1525 +
1526 + # $pagenow is set in wp-includes/vars.php, long before this filter runs;
1527 + # core's own wp_heartbeat_set_suspension() reads it exactly this way.
1528 + global $pagenow;
1529 + $lock_dependent_screens = array('post.php', 'post-new.php', 'customize.php');
1530 + if (isset($pagenow) && in_array($pagenow, $lock_dependent_screens, true)) {
1531 + return $settings;
1532 + }
1533 +
1534 + # 60s matches heartbeat.js's own default and stays well inside the
1535 + # 150s post-lock window, so nothing core does can fall through a gap.
1536 + $settings['interval'] = 60;
1337 1537 return $settings;
1338 1538 }
1339 1539
1340 1540 /**
@@ -1472,13 +1672,8 @@
1472 1672 {
1473 1673 Metasync_Connect_Manager::instance()->test_searchatlas_ajax_endpoint();
1474 1674 }
1475 1675
1476 - public function simple_ajax_test()
1477 - {
1478 - Metasync_Connect_Manager::instance()->simple_ajax_test();
1479 - }
1480 -
1481 1676 /**
1482 1677 * Create Admin Dashboard Iframe Page
1483 1678 * Embeds the Search Atlas dashboard directly in WordPress admin
1484 1679 */
@@ -1503,8 +1698,47 @@
1503 1698 // Get cache count
1504 1699 $cache_count = Metasync_Otto_Transient_Cache::get_cache_count();
1505 1700
1506 1701 ?>
1702 + <!-- OTTO Cache TTL Setting -->
1703 + <div style="margin-bottom: 30px; padding-top: 20px;">
1704 + <h4 style="margin-top: 0; color: var(--dashboard-text-primary);"><?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> Cache TTL</h4>
1705 + <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">
1706 + Configure how long <?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> API suggestions are cached before a fresh API call. The stale fallback expires after 12 hours.
1707 + </p>
1708 +
1709 + <div style="background: rgba(255,255,255,0.02); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
1710 + <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
1711 + <label for="metasync-otto-cache-ttl" style="color: var(--dashboard-text-primary); font-weight: 500;">
1712 + Cache TTL (minutes):
1713 + </label>
1714 + <?php Metasync::render_tooltip_icon('otto_cache_ttl', 'How many minutes OTTO reuses its last SEO suggestions before fetching fresh ones. 30 is fine for most sites.'); ?>
1715 + <input type="number"
1716 + id="metasync-otto-cache-ttl"
1717 + value="<?php echo esc_attr($this->get_otto_cache_ttl_minutes()); ?>"
1718 + min="30"
1719 + max="1440"
1720 + step="1"
1721 + style="width: 100px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg, rgba(255,255,255,0.05)); color: var(--dashboard-text-primary);" />
1722 + <span style="color: var(--dashboard-text-secondary); font-size: 13px;">min 30 · max 1440</span>
1723 + </div>
1724 +
1725 + <div style="display: flex; align-items: center; gap: 12px;">
1726 + <button type="button"
1727 + id="metasync-otto-ttl-save-btn"
1728 + class="metasync-btn-primary"
1729 + style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 9px 18px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease;"
1730 + onmouseover="this.style.transform='translateY(-1px)';"
1731 + onmouseout="this.style.transform='translateY(0)';">
1732 + Save TTL
1733 + </button>
1734 + <span id="metasync-otto-ttl-save-msg" style="display: none; font-size: 13px;"></span>
1735 + </div>
1736 +
1737 + <input type="hidden" id="metasync-otto-ttl-nonce" value="<?php echo esc_attr(wp_create_nonce('metasync_otto_cache_ttl_nonce')); ?>" />
1738 + </div>
1739 + </div>
1740 +
1507 1741 <!-- Cache Plugin Management -->
1508 1742 <div style="margin-bottom: 30px;">
1509 1743 <h4 style="margin-top: 0; color: var(--dashboard-text-primary);">Clear All Cache Plugins</h4>
1510 1744 <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">Clear all cache plugins to ensure changes are visible immediately.</p>
@@ -1526,9 +1760,9 @@
1526 1760 } else {
1527 1761 echo '<p style="color: var(--dashboard-text-secondary);">ℹ️ No cache plugins detected.</p>';
1528 1762 }
1529 1763 } catch (Exception $e) {
1530 - error_log('MetaSync Cache Status Error: ' . $e->getMessage());
1764 + error_log('MetaSync Cache Status Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1531 1765 echo '<p style="color: var(--dashboard-error);">⚠️ An error occurred while retrieving cache plugin status.</p>';
1532 1766 }
1533 1767 } else {
1534 1768 echo '<p style="color: var(--dashboard-error);">⚠️ Cache Purge class not loaded.</p>';
@@ -1545,12 +1779,12 @@
1545 1779 </form>
1546 1780
1547 1781 <?php
1548 1782 // Display success/error messages
1549 - if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') {
1550 - $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0;
1551 - $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0;
1552 - $plugins = isset($_GET['plugins']) ? sanitize_text_field(wp_unslash($_GET['plugins'])) : '';
1783 + if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1784 + $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1785 + $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1786 + $plugins = isset($_GET['plugins']) ? sanitize_text_field(wp_unslash($_GET['plugins'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1553 1787
1554 1788 if ($cleared > 0) {
1555 1789 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1556 1790 echo '✅ <strong>Success!</strong> Cleared cache for ' . intval( $cleared ) . ' plugin(s)';
@@ -1570,10 +1804,10 @@
1570 1804 echo '</p></div>';
1571 1805 }
1572 1806 }
1573 1807
1574 - if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') {
1575 - $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : '';
1808 + if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1809 + $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1576 1810 if (empty($message)) {
1577 1811 $message = 'An unknown error occurred while clearing cache. Please check error logs for details.';
1578 1812 }
1579 1813 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
@@ -1622,8 +1856,9 @@
1622 1856 <?php checked(true, !empty($hosting_settings['wpengine_enabled'])); ?>
1623 1857 <?php echo !$wpe_detected ? 'disabled' : ''; ?>
1624 1858 style="width: 16px; height: 16px; cursor: <?php echo $wpe_detected ? 'pointer' : 'not-allowed'; ?>;" />
1625 1859 <span style="color: var(--dashboard-text-primary); font-weight: 500;">WP Engine</span>
1860 + <?php Metasync::render_tooltip_icon('hosting_cache_wpengine', 'If hosted on WP Engine, turn on so OTTO clears the host\'s built-in cache when it updates a page.'); ?>
1626 1861 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges Varnish + Memcached</span>
1627 1862 </label>
1628 1863
1629 1864 <label style="display: flex; align-items: center; gap: 10px; margin-bottom: 16px; cursor: pointer;">
@@ -1632,8 +1867,9 @@
1632 1867 <?php checked(true, !empty($hosting_settings['kinsta_enabled'])); ?>
1633 1868 <?php echo !$kinsta_detected ? 'disabled' : ''; ?>
1634 1869 style="width: 16px; height: 16px; cursor: <?php echo $kinsta_detected ? 'pointer' : 'not-allowed'; ?>;" />
1635 1870 <span style="color: var(--dashboard-text-primary); font-weight: 500;">Kinsta</span>
1871 + <?php Metasync::render_tooltip_icon('hosting_cache_kinsta', 'If hosted on Kinsta, turn on so OTTO clears Kinsta\'s server cache automatically.'); ?>
1636 1872 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges full-page cache (kinsta_cache_purge_full)</span>
1637 1873 </label>
1638 1874
1639 1875 <div style="display: flex; align-items: center; gap: 12px;">
@@ -1647,9 +1883,9 @@
1647 1883 </button>
1648 1884 <span id="metasync-hc-save-msg" style="display: none; font-size: 13px;"></span>
1649 1885 </div>
1650 1886
1651 - <input type="hidden" id="metasync-hc-nonce" value="<?php echo wp_create_nonce('metasync_hosting_cache_nonce'); ?>" />
1887 + <input type="hidden" id="metasync-hc-nonce" value="<?php echo esc_attr(wp_create_nonce('metasync_hosting_cache_nonce')); ?>" />
1652 1888 </div>
1653 1889
1654 1890 <!-- Purge button -->
1655 1891 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
@@ -1668,12 +1904,13 @@
1668 1904 </form>
1669 1905
1670 1906 <?php
1671 1907 // Hosting cache result messages
1672 - if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') {
1673 - $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode($_GET['hc_cleared'])) : '';
1674 - $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode($_GET['hc_failed'])) : '';
1675 - $hc_not_detected = isset($_GET['hc_not_detected']) ? sanitize_text_field(urldecode($_GET['hc_not_detected'])) : '';
1908 + if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1909 + // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each value is unslashed and sanitized on the following assignments.
1910 + $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_cleared']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1911 + $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_failed']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1912 + $hc_not_detected = isset($_GET['hc_not_detected']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_not_detected']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1676 1913
1677 1914 if ($hc_cleared) {
1678 1915 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1679 1916 echo '✅ <strong>Success!</strong> Purged hosting cache on: ' . esc_html($hc_cleared);
@@ -1688,8 +1925,9 @@
1688 1925 echo '<div class="notice notice-info inline" style="margin-top: 10px;"><p>';
1689 1926 echo 'ℹ️ No enabled hosting providers were detected on this server (' . esc_html($hc_not_detected) . ').';
1690 1927 echo '</p></div>';
1691 1928 }
1929 + // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1692 1930 }
1693 1931 ?>
1694 1932
1695 1933 <script>
@@ -1724,8 +1962,46 @@
1724 1962 setTimeout(function() { $msg.fadeOut(); }, 4000);
1725 1963 }
1726 1964 });
1727 1965 });
1966 +
1967 + // OTTO Cache TTL save
1968 + $('#metasync-otto-ttl-save-btn').on('click', function() {
1969 + var $btn = $(this);
1970 + var $msg = $('#metasync-otto-ttl-save-msg');
1971 + var ttl = parseInt($('#metasync-otto-cache-ttl').val(), 10);
1972 +
1973 + if (isNaN(ttl) || ttl < 30 || ttl > 1440) {
1974 + $msg.text('❌ TTL must be between 30 and 1440 minutes.').css('color', '#ef4444').show();
1975 + return;
1976 + }
1977 +
1978 + $btn.prop('disabled', true).text('Saving…');
1979 +
1980 + $.ajax({
1981 + url: ajaxurl,
1982 + type: 'POST',
1983 + data: {
1984 + action: 'metasync_save_otto_cache_ttl',
1985 + otto_cache_ttl_nonce: $('#metasync-otto-ttl-nonce').val(),
1986 + otto_cache_ttl: ttl,
1987 + },
1988 + success: function(response) {
1989 + if (response.success) {
1990 + $msg.text('✅ Saved').css('color', '#22c55e').show();
1991 + } else {
1992 + $msg.text('❌ ' + (response.data && response.data.message ? response.data.message : 'Save failed')).css('color', '#ef4444').show();
1993 + }
1994 + },
1995 + error: function() {
1996 + $msg.text('❌ Request failed').css('color', '#ef4444').show();
1997 + },
1998 + complete: function() {
1999 + $btn.prop('disabled', false).text('Save TTL');
2000 + setTimeout(function() { $msg.fadeOut(); }, 4000);
2001 + }
2002 + });
2003 + });
1728 2004 });
1729 2005 </script>
1730 2006 </div>
1731 2007
@@ -1743,9 +2019,9 @@
1743 2019 id="metasync-targeted-object-cache"
1744 2020 <?php checked('1', $targeted_cache_enabled); ?>
1745 2021 style="width: 16px; height: 16px; cursor: pointer; margin-top: 2px; flex-shrink: 0;" />
1746 2022 <span>
1747 - <span style="color: var(--dashboard-text-primary); font-weight: 500; display: block; margin-bottom: 4px;">Targeted Object Cache Purge</span>
2023 + <span style="color: var(--dashboard-text-primary); font-weight: 500; display: block; margin-bottom: 4px;">Targeted Object Cache Purge<?php Metasync::render_tooltip_icon('targeted_object_cache_purge', 'Leave this on. Refreshes only the pages OTTO changed instead of wiping your whole site\'s memory cache — safer/faster on large sites.'); ?></span>
1748 2024 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">
1749 2025 When enabled, only the updated posts are evicted from the object cache (recommended for large sites).
1750 2026 When disabled, a full <code>wp_cache_flush()</code> is used instead.
1751 2027 </span>
@@ -1816,11 +2092,11 @@
1816 2092 </div>
1817 2093
1818 2094 <?php
1819 2095 // Display success/error messages
1820 - if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') {
1821 - $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0;
1822 - $url = isset($_GET['url']) ? urldecode(sanitize_text_field(wp_unslash($_GET['url']))) : '';
2096 + if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2097 + $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2098 + $url = isset($_GET['url']) ? urldecode(sanitize_text_field(wp_unslash($_GET['url']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
1823 2099
1824 2100 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1825 2101 if (!empty($url)) {
1826 2102 echo '✅ <strong>Success!</strong> Cleared cache for URL: <code>' . esc_html($url) . '</code> (' . intval( $cleared_count ) . ' entries)';
@@ -1829,10 +2105,10 @@
1829 2105 }
1830 2106 echo '</p></div>';
1831 2107 }
1832 2108
1833 - if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') {
1834 - $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : 'An unknown error occurred.';
2109 + if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2110 + $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : 'An unknown error occurred.'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
1835 2111 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
1836 2112 echo '❌ <strong>Error:</strong> ' . esc_html($message);
1837 2113 echo '</p></div>';
1838 2114 }
@@ -1871,9 +2147,9 @@
1871 2147 <td>
1872 2148 <input type="url"
1873 2149 id="otto_cache_url"
1874 2150 name="otto_cache_url"
1875 - value="<?php echo isset($_GET['url']) ? esc_attr(urldecode(sanitize_text_field(wp_unslash($_GET['url'])))) : ''; ?>"
2151 + value="<?php echo isset($_GET['url']) ? esc_attr(urldecode(sanitize_text_field(wp_unslash($_GET['url'])))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce ?>"
1876 2152 class="regular-text"
1877 2153 placeholder="https://example.com/page/"
1878 2154 required />
1879 2155 <p class="description" style="color: var(--dashboard-text-secondary);">Enter the full URL of the page whose cache you want to clear.</p>
@@ -1944,9 +2220,10 @@
1944 2220 /**
1945 2221 * AJAX handler for saving hosting cache settings
1946 2222 */
1947 2223 public function ajax_save_hosting_cache_settings() {
1948 - if (!isset($_POST['hosting_cache_nonce']) || !wp_verify_nonce($_POST['hosting_cache_nonce'], 'metasync_hosting_cache_nonce')) {
2224 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2225 + if (!isset($_POST['hosting_cache_nonce']) || !wp_verify_nonce(wp_unslash($_POST['hosting_cache_nonce']), 'metasync_hosting_cache_nonce')) {
1949 2226 wp_send_json_error(array('message' => 'Security check failed'));
1950 2227 return;
1951 2228 }
1952 2229
@@ -1967,9 +2244,10 @@
1967 2244 /**
1968 2245 * AJAX handler: save object cache behaviour settings
1969 2246 */
1970 2247 public function ajax_save_object_cache_settings() {
1971 - if (!isset($_POST['object_cache_nonce']) || !wp_verify_nonce($_POST['object_cache_nonce'], 'metasync_object_cache_nonce')) {
2248 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2249 + if (!isset($_POST['object_cache_nonce']) || !wp_verify_nonce(wp_unslash($_POST['object_cache_nonce']), 'metasync_object_cache_nonce')) {
1972 2250 wp_send_json_error(array('message' => 'Security check failed'));
1973 2251 return;
1974 2252 }
1975 2253
@@ -1983,12 +2261,52 @@
1983 2261 wp_send_json_success(array('message' => 'Object cache settings saved'));
1984 2262 }
1985 2263
1986 2264 /**
2265 + * Get OTTO Cache TTL value in minutes from execution settings.
2266 + *
2267 + * @return int
2268 + */
2269 + private function get_otto_cache_ttl_minutes() {
2270 + $execution_settings = get_option('metasync_execution_settings', array());
2271 + return isset($execution_settings['otto_cache_ttl']) ? absint($execution_settings['otto_cache_ttl']) : 30;
2272 + }
2273 +
2274 + /**
2275 + * AJAX handler for saving OTTO Cache TTL
2276 + */
2277 + public function ajax_save_otto_cache_ttl() {
2278 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2279 + if (!isset($_POST['otto_cache_ttl_nonce']) || !wp_verify_nonce(wp_unslash($_POST['otto_cache_ttl_nonce']), 'metasync_otto_cache_ttl_nonce')) {
2280 + wp_send_json_error(array('message' => 'Security check failed.'));
2281 + return;
2282 + }
2283 +
2284 + if (!current_user_can('manage_options')) {
2285 + wp_send_json_error(array('message' => 'Insufficient permissions.'));
2286 + return;
2287 + }
2288 +
2289 + $ttl = isset($_POST['otto_cache_ttl']) ? absint($_POST['otto_cache_ttl']) : 30;
2290 +
2291 + if ($ttl < 30 || $ttl > 1440) {
2292 + wp_send_json_error(array('message' => sprintf('%s Cache TTL must be between 30 and 1440 minutes.', Metasync::get_whitelabel_otto_name())));
2293 + return;
2294 + }
2295 +
2296 + $settings = get_option('metasync_execution_settings', array());
2297 + $settings['otto_cache_ttl'] = $ttl;
2298 + update_option('metasync_execution_settings', $settings);
2299 +
2300 + wp_send_json_success(array('message' => sprintf('%s Cache TTL saved.', Metasync::get_whitelabel_otto_name())));
2301 + }
2302 +
2303 + /**
1987 2304 * admin_post handler: purge WP Engine and Kinsta hosting-level caches
1988 2305 */
1989 2306 public function handle_purge_hosting_cache() {
1990 - if (!isset($_POST['hosting_cache_purge_nonce']) || !wp_verify_nonce($_POST['hosting_cache_purge_nonce'], 'metasync_hosting_cache_purge_nonce')) {
2307 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2308 + if (!isset($_POST['hosting_cache_purge_nonce']) || !wp_verify_nonce(wp_unslash($_POST['hosting_cache_purge_nonce']), 'metasync_hosting_cache_purge_nonce')) {
1991 2309 wp_die('Security check failed');
1992 2310 }
1993 2311
1994 2312 if (!Metasync::current_user_has_plugin_access()) {
@@ -2008,9 +2326,9 @@
2008 2326 WpeCommon::purge_varnish_cache();
2009 2327 WpeCommon::purge_memcached();
2010 2328 $cleared[] = 'WP Engine';
2011 2329 } catch (Exception $e) {
2012 - error_log('MetaSync: WP Engine hosting cache purge failed - ' . $e->getMessage());
2330 + error_log('MetaSync: WP Engine hosting cache purge failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2013 2331 $failed[] = 'WP Engine';
2014 2332 }
2015 2333 } else {
2016 2334 $not_detected[] = 'WP Engine';
@@ -2023,9 +2341,9 @@
2023 2341 try {
2024 2342 KinstaCache::get_instance()->kinsta_cache_purge_full();
2025 2343 $cleared[] = 'Kinsta';
2026 2344 } catch (Exception $e) {
2027 - error_log('MetaSync: Kinsta hosting cache purge failed - ' . $e->getMessage());
2345 + error_log('MetaSync: Kinsta hosting cache purge failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2028 2346 $failed[] = 'Kinsta';
2029 2347 }
2030 2348 } else {
2031 2349 $not_detected[] = 'Kinsta';
@@ -2420,9 +2738,9 @@
2420 2738 # Initialize MetaSync API request class and trigger synchronization
2421 2739 (new Metasync_Sync_Requests())->SyncCustomerParams();
2422 2740 } catch (Exception $e) {
2423 2741 # Log any API request errors for debugging
2424 - error_log('Metasync API Error: ' . $e->getMessage());
2742 + error_log('Metasync API Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2425 2743 }
2426 2744 }
2427 2745
2428 2746 /*
@@ -2437,9 +2755,9 @@
2437 2755 # Initialize MetaSync API request class and trigger synchronization
2438 2756 (new Metasync_Sync_Requests())->SyncCustomerParams();
2439 2757 } catch (Exception $e) {
2440 2758 # Log any API request errors for debugging
2441 - error_log('Metasync API Error: ' . $e->getMessage());
2759 + error_log('Metasync API Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2442 2760 }
2443 2761 }
2444 2762
2445 2763 /**
@@ -2472,8 +2790,15 @@
2472 2790 // Handle form submissions
2473 2791 if (isset($_POST['metasync_media_optimization_nonce'])) {
2474 2792 check_admin_referer('metasync_save_media_optimization', 'metasync_media_optimization_nonce');
2475 2793
2794 + // Settings shape the whole site's conversion pipeline — restrict
2795 + // the save (and the reset) to site admins, not just anyone who can
2796 + // open the page via plugin_access_roles.
2797 + if (!current_user_can('manage_options')) {
2798 + wp_die(esc_html__('Sorry, you are not allowed to manage media optimization settings.', 'metasync'));
2799 + }
2800 +
2476 2801 // Handle reset to defaults
2477 2802 if (!empty($_POST['metasync_media_reset'])) {
2478 2803 $defaults = Metasync_Media_Settings::get_defaults();
2479 2804 Metasync_Media_Settings::save_settings($defaults);
@@ -2478,8 +2803,9 @@
2478 2803 $defaults = Metasync_Media_Settings::get_defaults();
2479 2804 Metasync_Media_Settings::save_settings($defaults);
2480 2805 $save_success = true;
2481 2806 } elseif (isset($_POST['metasync_media'])) {
2807 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Media_Settings::save_settings().
2482 2808 $input = wp_unslash($_POST['metasync_media']);
2483 2809 Metasync_Media_Settings::save_settings($input);
2484 2810 $save_success = true;
2485 2811 }
@@ -2511,8 +2837,15 @@
2511 2837 * Code Minification page callback
2512 2838 */
2513 2839 public function create_admin_code_minification_page()
2514 2840 {
2841 + // Settings shape the whole site's asset pipeline — restrict
2842 + // the save (and the reset) to site admins, not just anyone who can
2843 + // open the page via plugin_access_roles.
2844 + if (!empty($_POST) && !current_user_can('manage_options')) {
2845 + wp_die(esc_html__('Sorry, you are not allowed to manage code minification settings.', 'metasync'));
2846 + }
2847 +
2515 2848 $this->render_layout_open('Code Minification', 'code_minification', 'Minify CSS, JavaScript, and HTML to improve performance.');
2516 2849 // Load settings and compatibility classes
2517 2850 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-settings.php';
2518 2851 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-cache.php';
@@ -2529,8 +2862,9 @@
2529 2862 $defaults = Metasync_Minification_Settings::get_defaults();
2530 2863 Metasync_Minification_Settings::save_settings($defaults);
2531 2864 $save_success = true;
2532 2865 } elseif (isset($_POST['metasync_code_min'])) {
2866 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Minification_Settings::save_settings().
2533 2867 $input = (array) wp_unslash($_POST['metasync_code_min']);
2534 2868 Metasync_Minification_Settings::save_settings($input);
2535 2869 $save_success = true;
2536 2870 }
@@ -2563,8 +2897,15 @@
2563 2897 if (!$attachment_id) {
2564 2898 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2565 2899 }
2566 2900
2901 + // Ownership: under the replace strategy this permanently deletes the
2902 + // attachment's original file, so upload_files alone is not enough —
2903 + // the caller must be allowed to edit this specific attachment.
2904 + if (!current_user_can('edit_post', $attachment_id)) {
2905 + wp_send_json_error(__('Permission denied.', 'metasync'));
2906 + }
2907 +
2567 2908 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-settings.php';
2568 2909 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-image-converter.php';
2569 2910 $settings = Metasync_Media_Settings::get_settings();
2570 2911
@@ -2578,8 +2919,9 @@
2578 2919 $max_bytes = 10 * 1024 * 1024;
2579 2920 if (filesize($file) > $max_bytes) {
2580 2921 $size_mb = round(filesize($file) / 1024 / 1024, 1);
2581 2922 wp_send_json_error(sprintf(
2923 + /* translators: %s: file size in megabytes. */
2582 2924 __('Optimization skipped: file size (%s MB) exceeds the 10 MB safety limit to prevent memory issues.', 'metasync'),
2583 2925 $size_mb
2584 2926 ));
2585 2927 }
@@ -2616,8 +2958,20 @@
2616 2958 if (!$attachment_id) {
2617 2959 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2618 2960 }
2619 2961
2962 + // Ownership: reverting deletes the attachment's converted files, so
2963 + // restrict it to users who may edit this specific attachment.
2964 + if (!current_user_can('edit_post', $attachment_id)) {
2965 + wp_send_json_error(__('Permission denied.', 'metasync'));
2966 + }
2967 +
2968 + // Replace-strategy conversions have no original to restore — reverting
2969 + // would delete the attachment's only file. Refuse up front, like bulk revert does.
2970 + if (!Metasync_Image_Converter::can_revert($attachment_id)) {
2971 + wp_send_json_error(__('This image cannot be reverted. The original file no longer exists (replace strategy).', 'metasync'));
2972 + }
2973 +
2620 2974 $success = Metasync_Image_Converter::revert_attachment($attachment_id);
2621 2975
2622 2976 if ($success) {
2623 2977 wp_send_json_success();
@@ -2669,8 +3023,15 @@
2669 3023 public function ajax_batch_progress()
2670 3024 {
2671 3025 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
2672 3026
3027 + // Read-only progress/stats polling — the same capability that can
3028 + // open the media optimization page is enough, but a nonce alone
3029 + // never was.
3030 + if (!current_user_can('upload_files')) {
3031 + wp_send_json_error(__('Permission denied.', 'metasync'));
3032 + }
3033 +
2673 3034 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-batch-optimizer.php';
2674 3035 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
2675 3036
2676 3037 $progress = Metasync_Media_Batch_Optimizer::get_progress();
@@ -2701,10 +3062,19 @@
2701 3062 $settings = Metasync_Media_Settings::get_settings();
2702 3063
2703 3064 $success = 0;
2704 3065 $failed = 0;
3066 + $denied = 0;
2705 3067
2706 3068 foreach ($ids as $id) {
3069 + // Per-attachment ownership: replace mode deletes originals, so
3070 + // upload_files alone must not allow converting images the caller
3071 + // does not own/cannot edit.
3072 + if (!current_user_can('edit_post', $id)) {
3073 + $denied++;
3074 + continue;
3075 + }
3076 +
2707 3077 if (Metasync_Image_Converter::convert_attachment($id, $settings)) {
2708 3078 $success++;
2709 3079 } else {
2710 3080 $failed++;
@@ -2713,8 +3083,9 @@
2713 3083
2714 3084 wp_send_json_success([
2715 3085 'success' => $success,
2716 3086 'failed' => $failed,
3087 + 'denied' => $denied,
2717 3088 ]);
2718 3089 }
2719 3090
2720 3091 /**
@@ -2737,11 +3108,19 @@
2737 3108
2738 3109 $success = 0;
2739 3110 $failed = 0;
2740 3111 $skipped = 0;
3112 + $denied = 0;
2741 3113 $errors = [];
2742 3114
2743 3115 foreach ($ids as $id) {
3116 + // Per-attachment ownership: reverting deletes the attachment's
3117 + // converted files, so restrict it per image, not per role.
3118 + if (!current_user_can('edit_post', $id)) {
3119 + $denied++;
3120 + continue;
3121 + }
3122 +
2744 3123 $format = get_post_meta($id, '_metasync_converted_format', true);
2745 3124
2746 3125 if (!$format) {
2747 3126 $skipped++;
@@ -2751,8 +3130,9 @@
2751 3130 if (!Metasync_Image_Converter::can_revert($id)) {
2752 3131 $skipped++;
2753 3132 $file = get_attached_file($id);
2754 3133 $name = $file ? basename($file) : "ID {$id}";
3134 + /* translators: %s: attachment file name. */
2755 3135 $errors[] = sprintf(__('%s: skipped — original image unavailable.', 'metasync'), $name);
2756 3136 continue;
2757 3137 }
2758 3138
@@ -2761,8 +3141,9 @@
2761 3141 } else {
2762 3142 $failed++;
2763 3143 $file = get_attached_file($id);
2764 3144 $name = $file ? basename($file) : "ID {$id}";
3145 + /* translators: %s: attachment file name. */
2765 3146 $errors[] = sprintf(__('%s: revert failed (original file may not exist).', 'metasync'), $name);
2766 3147 }
2767 3148 }
2768 3149
@@ -2769,8 +3150,9 @@
2769 3150 wp_send_json_success([
2770 3151 'success' => $success,
2771 3152 'failed' => $failed,
2772 3153 'skipped' => $skipped,
3154 + 'denied' => $denied,
2773 3155 'errors' => $errors,
2774 3156 ]);
2775 3157 }
2776 3158
@@ -2794,8 +3176,49 @@
2794 3176 wp_send_json_success($progress);
2795 3177 }
2796 3178
2797 3179 /**
3180 + * AJAX: Delete an orphaned media record (attachment whose file is missing).
3181 + *
3182 + * Only deletes when the file is confirmed missing on disk, so valid
3183 + * attachments can never be removed through this endpoint.
3184 + */
3185 + public function ajax_delete_orphaned_image()
3186 + {
3187 + check_ajax_referer('metasync_media_opt_nonce', 'nonce');
3188 +
3189 + $attachment_id = isset($_POST['attachment_id']) ? absint($_POST['attachment_id']) : 0;
3190 + if (!$attachment_id) {
3191 + wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
3192 + }
3193 +
3194 + if (!current_user_can('delete_post', $attachment_id)) {
3195 + wp_send_json_error(__('Permission denied.', 'metasync'));
3196 + }
3197 +
3198 + if (get_post_type($attachment_id) !== 'attachment') {
3199 + wp_send_json_error(__('Not an attachment.', 'metasync'));
3200 + }
3201 +
3202 + require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
3203 +
3204 + // Guard: only orphaned records (missing file) may be deleted here.
3205 + if (!Metasync_Media_Library_List_Table::is_file_missing($attachment_id)) {
3206 + wp_send_json_error(__('The image file exists; refusing to delete a valid attachment.', 'metasync'));
3207 + }
3208 +
3209 + $deleted = wp_delete_attachment($attachment_id, true);
3210 +
3211 + if ($deleted) {
3212 + wp_send_json_success([
3213 + 'stats' => Metasync_Media_Library_List_Table::get_stats(),
3214 + ]);
3215 + }
3216 +
3217 + wp_send_json_error(__('Failed to delete the orphaned media record.', 'metasync'));
3218 + }
3219 +
3220 + /**
2798 3221 * Cron handler: Process batch optimization tick.
2799 3222 */
2800 3223 public function handle_media_batch_cron()
2801 3224 {
@@ -2805,8 +3228,86 @@
2805 3228 Metasync_Media_Batch_Optimizer::process_batch_tick();
2806 3229 }
2807 3230
2808 3231 /**
3232 + * AJAX: Start SEO Restore batch.
3233 + */
3234 + public function ajax_seo_restore_start()
3235 + {
3236 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3237 +
3238 + if (!current_user_can('manage_options')) {
3239 + wp_send_json_error(__('Permission denied.', 'metasync'));
3240 + }
3241 +
3242 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3243 +
3244 + $progress = Metasync_Seo_Restore::start_batch();
3245 + wp_send_json_success($progress);
3246 + }
3247 +
3248 + /**
3249 + * AJAX: Cancel SEO Restore batch.
3250 + */
3251 + public function ajax_seo_restore_cancel()
3252 + {
3253 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3254 +
3255 + if (!current_user_can('manage_options')) {
3256 + wp_send_json_error(__('Permission denied.', 'metasync'));
3257 + }
3258 +
3259 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3260 +
3261 + Metasync_Seo_Restore::cancel_batch();
3262 + wp_send_json_success();
3263 + }
3264 +
3265 + /**
3266 + * AJAX: Get SEO Restore batch progress.
3267 + */
3268 + public function ajax_seo_restore_progress()
3269 + {
3270 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3271 +
3272 + if (!current_user_can('manage_options')) {
3273 + wp_send_json_error(__('Permission denied.', 'metasync'));
3274 + }
3275 +
3276 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3277 +
3278 + $progress = Metasync_Seo_Restore::get_progress();
3279 + wp_send_json_success($progress);
3280 + }
3281 +
3282 + /**
3283 + * AJAX: Process one SEO Restore tick (browser-driven chaining).
3284 + */
3285 + public function ajax_seo_restore_process_tick()
3286 + {
3287 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3288 +
3289 + if (!current_user_can('manage_options')) {
3290 + wp_send_json_error(__('Permission denied.', 'metasync'));
3291 + }
3292 +
3293 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3294 +
3295 + $progress = Metasync_Seo_Restore::process_ajax_tick();
3296 + wp_send_json_success($progress);
3297 + }
3298 +
3299 + /**
3300 + * Cron handler: Process SEO Restore batch tick.
3301 + */
3302 + public function handle_seo_restore_cron()
3303 + {
3304 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3305 +
3306 + Metasync_Seo_Restore::process_batch_tick();
3307 + }
3308 +
3309 + /**
2809 3310 * Report Issue page callback
2810 3311 */
2811 3312 public function create_admin_report_issue_page()
2812 3313 {
@@ -2818,8 +3319,17 @@
2818 3319 * XML Sitemap page callback
2819 3320 */
2820 3321 public function create_admin_xml_sitemap_page()
2821 3322 {
3323 + // Every form action below mutates site-wide state — sitemap options,
3324 + // sitemap files on disk, robots.txt, other plugins' sitemap generators
3325 + // — so they are restricted to site admins, not just anyone who can
3326 + // open the page via plugin_access_roles. The nonce is CSRF
3327 + // protection, not authorization.
3328 + if (!empty($_POST) && !current_user_can('manage_options')) {
3329 + wp_die(esc_html__('Sorry, you are not allowed to manage sitemap settings.', 'metasync'));
3330 + }
3331 +
2822 3332 // Load sitemap generator class
2823 3333 require_once plugin_dir_path(dirname(__FILE__)) . 'sitemap/class-metasync-sitemap-generator.php';
2824 3334
2825 3335 $sitemap_generator = new Metasync_Sitemap_Generator();
@@ -2850,10 +3360,13 @@
2850 3360 // Regenerate sitemap with new content settings
2851 3361 $result = $sitemap_generator->generate_sitemap();
2852 3362 if (is_wp_error($result)) {
2853 3363 echo '<div class="notice notice-error"><p>' . esc_html(
3364 + /* translators: %s: error message. */
2854 3365 sprintf(__('Settings saved but sitemap generation failed: %s', 'metasync'), $result->get_error_message())
2855 3366 ) . '</p></div>';
3367 + } elseif (false === $result) {
3368 + echo '<div class="notice notice-error"><p>' . esc_html__('Settings saved but sitemap generation failed: the sitemap data could not be stored, so no sitemap is being served. Check your object cache and database write settings, then try again.', 'metasync') . '</p></div>';
2856 3369 } else {
2857 3370 echo '<div class="notice notice-success"><p>' . esc_html__('Sitemap content settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
2858 3371 }
2859 3372 }
@@ -2864,10 +3377,10 @@
2864 3377
2865 3378 // Build generic taxonomy filters
2866 3379 $news_taxonomies = [];
2867 3380 if (!empty($_POST['news_taxonomies']) && is_array($_POST['news_taxonomies'])) {
2868 - foreach ($_POST['news_taxonomies'] as $tax_name => $term_ids) {
2869 - $news_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) $term_ids);
3381 + foreach (wp_unslash($_POST['news_taxonomies']) as $tax_name => $term_ids) {
3382 + $news_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) wp_unslash($term_ids));
2870 3383 }
2871 3384 }
2872 3385
2873 3386 $news_settings = [
@@ -2892,9 +3405,9 @@
2892 3405 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
2893 3406 } else {
2894 3407 // Also remove physical file if it exists
2895 3408 if (file_exists(ABSPATH . 'news-sitemap.xml')) {
2896 - @unlink(ABSPATH . 'news-sitemap.xml');
3409 + @wp_delete_file(ABSPATH . 'news-sitemap.xml');
2897 3410 }
2898 3411 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
2899 3412 }
2900 3413 }
@@ -2905,10 +3418,10 @@
2905 3418
2906 3419 // Build generic taxonomy filters
2907 3420 $video_taxonomies = [];
2908 3421 if (!empty($_POST['video_taxonomies']) && is_array($_POST['video_taxonomies'])) {
2909 - foreach ($_POST['video_taxonomies'] as $tax_name => $term_ids) {
2910 - $video_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) $term_ids);
3422 + foreach (wp_unslash($_POST['video_taxonomies']) as $tax_name => $term_ids) {
3423 + $video_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) wp_unslash($term_ids));
2911 3424 }
2912 3425 }
2913 3426
2914 3427 $video_settings = [
@@ -2916,8 +3429,12 @@
2916 3429 'post_types' => array_map('sanitize_key', (array) ($_POST['video_post_types'] ?? ['post', 'page'])),
2917 3430 'auto_detect' => isset($_POST['auto_detect']),
2918 3431 'taxonomies' => $video_taxonomies,
2919 3432 'excluded_urls' => sanitize_textarea_field(wp_unslash($_POST['video_excluded_urls'] ?? '')),
3433 + // This array replaces the stored option wholesale, so every
3434 + // setting must be represented here or it is wiped on save.
3435 + 'video_url_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_url_meta_keys'] ?? '')),
3436 + 'video_thumbnail_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_thumbnail_meta_keys'] ?? '')),
2920 3437 ];
2921 3438
2922 3439 // Always invalidate old cache before saving new settings
2923 3440 delete_transient('metasync_vsm_' . md5('video-sitemap.xml'));
@@ -2930,9 +3447,9 @@
2930 3447 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
2931 3448 } else {
2932 3449 // Also remove physical file if it exists
2933 3450 if (file_exists(ABSPATH . 'video-sitemap.xml')) {
2934 - @unlink(ABSPATH . 'video-sitemap.xml');
3451 + @wp_delete_file(ABSPATH . 'video-sitemap.xml');
2935 3452 }
2936 3453 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
2937 3454 }
2938 3455 }
@@ -2975,8 +3492,17 @@
2975 3492 if ($sitemap_generator->generate_news_sitemap()) {
2976 3493 $extras[] = 'news';
2977 3494 }
2978 3495 }
3496 +
3497 + // Reclaim unreferenced memory between the news and video passes
3498 + // so the video generator starts with more headroom (they share
3499 + // one request). Each generator already releases its own object
3500 + // cache via clean_post_cache(), so no cache flush is needed here.
3501 + if (function_exists('gc_collect_cycles')) {
3502 + gc_collect_cycles();
3503 + }
3504 +
2979 3505 if (!empty($video_opts['enabled'])) {
2980 3506 if ($sitemap_generator->generate_video_sitemap()) {
2981 3507 $extras[] = 'video';
2982 3508 }
@@ -2981,21 +3507,30 @@
2981 3507 $extras[] = 'video';
2982 3508 }
2983 3509 }
2984 3510
3511 + // Same inter-pass GC before the main sitemap pass begins.
3512 + if (function_exists('gc_collect_cycles')) {
3513 + gc_collect_cycles();
3514 + }
3515 +
2985 3516 // Generate main sitemap (its index will include news/video since they now exist)
2986 3517 $result = $sitemap_generator->generate_sitemap();
2987 3518
2988 3519 if (is_wp_error($result)) {
2989 3520 $error_msg = $result->get_error_message();
2990 - error_log('[MetaSync] Sitemap generation failed: ' . $error_msg);
3521 + error_log('[MetaSync] Sitemap generation failed: ' . $error_msg); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2991 3522 echo '<div class="notice notice-error"><p>' . esc_html(
3523 + /* translators: %s: error message. */
2992 3524 sprintf(__('Sitemap generation failed: %s', 'metasync'), $error_msg)
2993 3525 ) . '</p></div>';
3526 + } elseif (false === $result) {
3527 + echo '<div class="notice notice-error"><p>' . esc_html__('Sitemap generation failed: the sitemap data could not be stored, so no sitemap is being served. Check your object cache and database write settings, then try again.', 'metasync') . '</p></div>';
2994 3528 } else {
2995 3529 $message = esc_html__('Sitemap generated successfully!', 'metasync');
2996 3530 if (!empty($extras)) {
2997 3531 $message .= ' ' . sprintf(
3532 + /* translators: %s: list of sitemap types. */
2998 3533 esc_html__('Also generated %s sitemap(s).', 'metasync'),
2999 3534 implode(' & ', $extras)
3000 3535 );
3001 3536 }
@@ -3030,8 +3565,12 @@
3030 3565
3031 3566 if ($deleted) {
3032 3567 // Disable auto-update only when the general sitemap is removed
3033 3568 update_option('metasync_sitemap_auto_update', false);
3569 + // Re-enable WP core sitemap only if no other MetaSync sitemaps remain
3570 + if (!$sitemap_generator->sitemap_exists()) {
3571 + delete_option('metasync_disable_wp_sitemap');
3572 + }
3034 3573 echo '<div class="notice notice-success"><p>' . esc_html__('General sitemap deleted successfully!', 'metasync') . '</p></div>';
3035 3574 } else {
3036 3575 echo '<div class="notice notice-error"><p>' . esc_html__('Failed to delete general sitemap. The files may not exist or are not writable.', 'metasync') . '</p></div>';
3037 3576 }
@@ -3057,8 +3596,10 @@
3057 3596
3058 3597 if ($deleted) {
3059 3598 // Also disable auto-update when deleting
3060 3599 update_option('metasync_sitemap_auto_update', false);
3600 + // Re-enable WP core sitemap so the site isn't left with zero sitemaps
3601 + delete_option('metasync_disable_wp_sitemap');
3061 3602 echo '<div class="notice notice-success"><p>' . esc_html__('All sitemaps deleted successfully!', 'metasync') . '</p></div>';
3062 3603 } else {
3063 3604 echo '<div class="notice notice-error"><p>' . esc_html__('Failed to delete sitemaps. The files may not exist or are not writable.', 'metasync') . '</p></div>';
3064 3605 }
@@ -3178,47 +3719,8 @@
3178 3719 Metasync_Admin_Pages::get_instance($this)->create_admin_breadcrumbs_page();
3179 3720 }
3180 3721
3181 3722 /**
3182 - * Google Instant Index Setting page callback
3183 - */
3184 - public function create_admin_google_instant_index_page()
3185 - {
3186 - $this->render_layout_open('Instant Indexing', 'instant_index', 'Submit URLs to Google for instant indexing via the Indexing API.');
3187 -
3188 - // Render shared Google Index credentials section
3189 - if (!function_exists('google_index_direct')) {
3190 - if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) {
3191 - require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
3192 - } else {
3193 - error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php');
3194 - return;
3195 - }
3196 - }
3197 - $google_index = google_index_direct();
3198 - $service_info = $google_index->get_service_account_info();
3199 - $is_configured = !isset($service_info['error']);
3200 -
3201 - $saved_json_display = $is_configured ? $google_index->get_redacted_config_json() : '';
3202 -
3203 - include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-index-api-settings.php';
3204 -
3205 - // Render post types selection with save form
3206 - $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
3207 - $post_types_settings = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
3208 - ?>
3209 - <form method="POST" action="">
3210 - <?php include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-instant-post-types.php'; ?>
3211 - <div class="dashboard-card" style="padding: 20px;">
3212 - <?php submit_button('Save Post Types', 'primary', 'submit', false, array('class' => 'button button-primary')); ?>
3213 - </div>
3214 - </form>
3215 - <?php
3216 -
3217 - $this->render_layout_close();
3218 - }
3219 -
3220 - /**
3221 3723 * Google Console page callback
3222 3724 */
3223 3725 public function create_admin_google_console_page()
3224 3726 {
@@ -3241,16 +3743,8 @@
3241 3743 $this->render_layout_close();
3242 3744 }
3243 3745
3244 3746 /**
3245 - * General Options page callback
3246 - */
3247 - public function create_admin_optimal_settings_page()
3248 - {
3249 - Metasync_Admin_Pages::get_instance($this)->create_admin_optimal_settings_page();
3250 - }
3251 -
3252 - /**
3253 3747 * Global Options page callback
3254 3748 */
3255 3749 public function create_admin_global_settings_page()
3256 3750 {
@@ -3282,16 +3776,8 @@
3282 3776 Metasync_Admin_Pages::get_instance($this)->create_admin_seo_controls_page();
3283 3777 }
3284 3778
3285 3779 /**
3286 - * Site Optimal Settings page callback
3287 - */
3288 - public function optimization_settings_options()
3289 - {
3290 - Metasync_Admin_Pages::get_instance($this)->optimization_settings_options();
3291 - }
3292 -
3293 - /**
3294 3780 * redirection page callback with tabs
3295 3781 */
3296 3782 public function create_admin_redirections_page()
3297 3783 {
@@ -3318,10 +3804,11 @@
3318 3804 }
3319 3805 delete_transient( Metasync_CPU_Monitor::DEFER_NOTICE_TRANSIENT );
3320 3806 echo '<div class="notice notice-warning is-dismissible"><p>';
3321 3807 printf(
3322 - /* translators: 1: current load, 2: threshold, 3: core count */
3323 - esc_html__( 'MetaSync: Batch processing was deferred — server CPU load (%1$s) exceeded the threshold (%2$s on %3$s cores). Processing will resume automatically.', 'metasync' ),
3808 + /* translators: 1: plugin name, 2: current load, 3: threshold, 4: core count */
3809 + esc_html__( '%1$s: Batch processing was deferred — server CPU load (%2$s) exceeded the threshold (%3$s on %4$s cores). Processing will resume automatically.', 'metasync' ),
3810 + esc_html( Metasync::get_effective_plugin_name() ),
3324 3811 esc_html( $data['load'] ),
3325 3812 esc_html( $data['threshold'] ),
3326 3813 esc_html( $data['cores'] )
3327 3814 );
@@ -3339,9 +3826,10 @@
3339 3826 if (!get_transient('metasync_llms_conflict')) {
3340 3827 return;
3341 3828 }
3342 3829 echo '<div class="notice notice-info is-dismissible"><p>';
3343 - echo esc_html__('Note: Another SEO plugin (Yoast, Rank Math, or AIOSEO) may also be generating /llms.txt. MetaSync\'s version takes priority when enabled.', 'metasync');
3830 + /* translators: %s: effective plugin name (whitelabel-aware). */
3831 + echo esc_html(sprintf(__('Note: Another SEO plugin (Yoast, Rank Math, or AIOSEO) may also be generating /llms.txt. %s\'s version takes priority when enabled.', 'metasync'), Metasync::get_effective_plugin_name()));
3344 3832 echo '</p></div>';
3345 3833 }
3346 3834
3347 3835 /**
@@ -3410,8 +3898,17 @@
3410 3898 public function ajax_restore_robots_backup()
3411 3899 {
3412 3900 Metasync_Admin_Ajax::instance()->ajax_restore_robots_backup();
3413 3901 }
3902 +
3903 + /**
3904 + * AJAX handler to fetch a paginated page of robots.txt backups
3905 + */
3906 + public function ajax_get_robots_backups()
3907 + {
3908 + Metasync_Admin_Ajax::instance()->ajax_get_robots_backups();
3909 + }
3910 +
3414 3911 public function ajax_create_redirect_from_404()
3415 3912 {
3416 3913 Metasync_Admin_Ajax::instance()->ajax_create_redirect_from_404();
3417 3914 }
@@ -3523,17 +4020,17 @@
3523 4020 return;
3524 4021 }
3525 4022
3526 4023 // Get pagination parameters
3527 - $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
3528 - $per_page = 10;
4024 + $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
4025 + $per_page = Metasync_Per_Page_Helper::resolve('sync_log', 10);
3529 4026 $offset = ($page - 1) * $per_page;
3530 4027
3531 4028 // Get filters
3532 4029 $filters = [
3533 4030 // UI exposes date_range and status only. We compute date_from/date_to based on date_range
3534 - 'date_range' => isset($_GET['date_range']) ? sanitize_text_field(wp_unslash($_GET['date_range'])) : '',
3535 - 'status' => isset($_GET['status']) ? sanitize_text_field(wp_unslash($_GET['status'])) : '',
4031 + 'date_range' => isset($_GET['date_range']) ? sanitize_text_field(wp_unslash($_GET['date_range'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
4032 + 'status' => isset($_GET['status']) ? sanitize_text_field(wp_unslash($_GET['status'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
3536 4033 ];
3537 4034
3538 4035 // Map date_range to concrete date_from/date_to for DB queries
3539 4036 $date_range = $filters['date_range'];
@@ -3542,28 +4039,28 @@
3542 4039 $date_to = '';
3543 4040
3544 4041 if (!empty($date_range)) {
3545 4042 // End boundary is now by default
3546 - $date_to = date('Y-m-d H:i:s', $wp_now_ts);
4043 + $date_to = gmdate('Y-m-d H:i:s', $wp_now_ts);
3547 4044
3548 4045 if ($date_range === 'today') {
3549 4046 $start_ts = strtotime('today', $wp_now_ts);
3550 - $date_from = date('Y-m-d H:i:s', $start_ts);
4047 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3551 4048 } elseif ($date_range === 'yesterday') {
3552 4049 $start_ts = strtotime('yesterday', $wp_now_ts);
3553 4050 $end_ts = strtotime('today', $wp_now_ts) - 1; // end of yesterday
3554 - $date_from = date('Y-m-d H:i:s', $start_ts);
3555 - $date_to = date('Y-m-d H:i:s', $end_ts);
4051 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
4052 + $date_to = gmdate('Y-m-d H:i:s', $end_ts);
3556 4053 } elseif ($date_range === 'this_week') {
3557 4054 $start_of_week = (int) get_option('start_of_week', 1); // 0=Sun, 1=Mon
3558 - $day_of_week = (int) date('w', $wp_now_ts); // 0=Sun..6=Sat
4055 + $day_of_week = (int) gmdate('w', $wp_now_ts); // 0=Sun..6=Sat
3559 4056 // Convert start_of_week to PHP's 0..6 where 0=Sunday
3560 4057 $delta_days = ($day_of_week - $start_of_week + 7) % 7;
3561 4058 $start_ts = strtotime('-' . $delta_days . ' days', strtotime('today', $wp_now_ts));
3562 - $date_from = date('Y-m-d H:i:s', $start_ts);
4059 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3563 4060 } elseif ($date_range === 'this_month') {
3564 - $start_ts = strtotime(date('Y-m-01 00:00:00', $wp_now_ts));
3565 - $date_from = date('Y-m-d H:i:s', $start_ts);
4061 + $start_ts = strtotime(gmdate('Y-m-01 00:00:00', $wp_now_ts));
4062 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3566 4063 } elseif ($date_range === 'all') {
3567 4064 // no bounds
3568 4065 }
3569 4066 }
@@ -3590,12 +4087,24 @@
3590 4087 ?>
3591 4088 <div class="dashboard-card">
3592 4089 <div class="sync-log-header">
3593 4090 <div class="sync-log-title-section">
3594 - <h2>Changes Log</h2>
3595 - <p style="color: var(--dashboard-text-secondary); margin-bottom: 0;">
3596 - Recent content synchronizations from external tools.
3597 - <span style="margin-left:8px; font-size:12px; opacity:.75;">Records are automatically removed after 90 days.</span>
4091 + <?php
4092 + // The page title and description are already rendered by
4093 + // render_layout_open() above. Repeating them here printed
4094 + // "Changes Log" twice on screen, so the card keeps only the
4095 + // retention hint — the one piece of copy the layout header
4096 + // does not carry.
4097 + ?>
4098 + <p style="color: var(--dashboard-text-secondary); margin-top: 0; margin-bottom: 0;">
4099 + <?php
4100 + // No opacity here: this used to be a de-emphasised
4101 + // aside trailing a full-contrast sentence, but it is
4102 + // now the card's only copy. At 12px/.75 it composites
4103 + // to 4.28:1 on the card background, under the 4.5:1
4104 + // AA floor; at full strength it reads 6.52:1.
4105 + ?>
4106 + <span style="font-size:13px;">Records are automatically removed after 90 days.</span>
3598 4107 </p>
3599 4108 </div>
3600 4109
3601 4110 <!-- Filters + Clear Log button - Right aligned -->
@@ -3604,10 +4113,10 @@
3604 4113 style="background:#dc3545;color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:13px;"
3605 4114 data-nonce="<?php echo esc_attr(wp_create_nonce('metasync_clear_sync_log')); ?>">
3606 4115 🗑 Clear Log
3607 4116 </button>
3608 - <form method="get" class="sync-filters-form" onchange="this.submit()">
3609 - <input type="hidden" name="page" value="<?php echo esc_attr($_GET['page']); ?>">
4117 + <form method="get" class="sync-filters-form" onchange="this.submit()" style="display:flex;flex-direction:row;align-items:center;gap:12px;flex-wrap:nowrap;">
4118 + <input type="hidden" name="page" value="<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>">
3610 4119
3611 4120 <select name="date_range" class="sync-filter-select">
3612 4121 <option value="all" <?php selected($filters['date_range'] ?? 'all', 'all'); ?>> All Time</option>
3613 4122 <option value="today" <?php selected($filters['date_range'] ?? '', 'today'); ?>>Today</option>
@@ -3629,9 +4138,9 @@
3629 4138 <div class="sync-log-list">
3630 4139 <?php if (empty($sync_records)): ?>
3631 4140 <div class="sync-log-empty">
3632 4141 <div class="sync-log-empty-icon"><span class="dashicons dashicons-media-default" style="font-size:48px;width:48px;height:48px;color:var(--dashboard-text-secondary);"></span></div>
3633 - <h3>No sync records found</h3>
4142 + <h2>No sync records found</h2>
3634 4143 <p>Sync records will appear here when content/pages receive new updates.</p>
3635 4144 </div>
3636 4145 <?php else: ?>
3637 4146 <?php foreach ($sync_records as $record): ?>
@@ -3656,19 +4165,20 @@
3656 4165 </div>
3657 4166 </div>
3658 4167
3659 4168 <div class="sync-log-status" style="display:flex;align-items:center;gap:8px;">
3660 - <?php if ($record->status === 'published' || $record->status === 'publish'): ?>
3661 - <span class="sync-status-badge sync-status-published">
3662 - <span class="sync-status-icon">✓</span>
3663 - Published
3664 - </span>
3665 - <?php else: ?>
3666 - <span class="sync-status-badge sync-status-draft">
3667 - <span class="sync-status-icon">i</span>
3668 - Draft
3669 - </span>
3670 - <?php endif; ?>
4169 + <?php
4170 + $st = (string) $record->status;
4171 + $b_label = ucfirst($st); $b_bg = '#6b7280'; $b_icon = 'dashicons-info-outline';
4172 + if ($st === 'published' || $st === 'publish' || $st === 'success' || $st === 'partial') { $b_label = 'Published'; $b_bg = '#16a34a'; $b_icon = 'dashicons-yes'; }
4173 + elseif ($st === 'updated') { $b_label = 'Updated'; $b_bg = '#0d9488'; $b_icon = 'dashicons-update'; }
4174 + elseif ($st === 'failed' || $st === 'conflict' || $st === 'locked') { $b_label = 'Not imported'; $b_bg = '#64748b'; $b_icon = 'dashicons-minus'; }
4175 + elseif ($st === 'draft') { $b_label = 'Draft'; $b_bg = '#6b7280'; $b_icon = 'dashicons-info-outline'; }
4176 + ?>
4177 + <span class="sync-status-badge" style="display:inline-flex;align-items:center;gap:4px;background:<?php echo esc_attr($b_bg); ?>;color:#fff;padding:3px 10px;border-radius:12px;font-size:12px;font-weight:600;line-height:1;">
4178 + <span class="dashicons <?php echo esc_attr($b_icon); ?>" style="font-size:14px;width:14px;height:14px;line-height:14px;"></span>
4179 + <?php echo esc_html($b_label); ?>
4180 + </span>
3671 4181 <?php if ($record->source === 'MCP Client'): ?>
3672 4182 <button type="button"
3673 4183 class="metasync-rollback-btn"
3674 4184 data-id="<?php echo esc_attr($record->id); ?>"
@@ -3684,30 +4194,32 @@
3684 4194 <?php endif; ?>
3685 4195 </div>
3686 4196
3687 4197 <!-- Pagination -->
3688 - <?php if ($total_pages > 1): ?>
3689 - <div class="sync-log-pagination">
3690 - <div class="sync-log-pagination-info">
3691 - Total records: <?php echo intval( $total_records ); ?> | Showing <?php echo intval( $offset ) + 1; ?>-<?php echo intval( min($offset + $per_page, $total_records) ); ?>
3692 - </div>
4198 + <div class="sync-log-pagination">
4199 + <div class="sync-log-pagination-info">
4200 + Total records: <?php echo intval( $total_records ); ?><?php if ($total_records > 0): ?> | Showing <?php echo intval( $offset ) + 1; ?>-<?php echo intval( min($offset + $per_page, $total_records) ); ?><?php endif; ?>
4201 + <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper returns a complete escaped selector element.
4202 + echo Metasync_Per_Page_Helper::render_selector('sync_log', absint($per_page)); ?>
4203 + </div>
3693 4204
4205 + <?php if ($total_pages > 1): ?>
3694 4206 <div class="sync-log-pagination-controls">
3695 4207 <?php if ($page > 1): ?>
3696 - <a href="?page=<?php echo esc_attr($_GET['page']); ?>&paged=<?php echo intval( $page ) - 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">‹</a>
4208 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $page ) - 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">‹</a>
3697 4209 <?php endif; ?>
3698 4210
3699 4211 <?php for ($i = max(1, $page - 2); $i <= min($total_pages, $page + 2); $i++): ?>
3700 - <a href="?page=<?php echo esc_attr($_GET['page']); ?>&paged=<?php echo intval( $i ); ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>"
4212 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $i ); ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>"
3701 4213 class="sync-pagination-btn <?php echo $i === $page ? 'active' : ''; ?>"><?php echo intval( $i ); ?></a>
3702 4214 <?php endfor; ?>
3703 4215
3704 4216 <?php if ($page < $total_pages): ?>
3705 - <a href="?page=<?php echo esc_attr($_GET['page']); ?>&paged=<?php echo intval( $page ) + 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">›</a>
4217 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $page ) + 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">›</a>
3706 4218 <?php endif; ?>
3707 4219 </div>
3708 - </div>
3709 - <?php endif; ?>
4220 + <?php endif; ?>
4221 + </div>
3710 4222 </div>
3711 4223 <?php $this->render_layout_close(); ?>
3712 4224
3713 4225 <script>
@@ -3788,8 +4300,19 @@
3788 4300 if (!empty($value)) {
3789 4301 $query_parts[] = $key . '=' . urlencode($value);
3790 4302 }
3791 4303 }
4304 +
4305 + // Preserve the user-selected results-per-page value across page
4306 + // navigation so a non-default page size survives clicking a page link.
4307 + $per_page_key = Metasync_Per_Page_Helper::request_key('sync_log');
4308 + if (isset($_GET[$per_page_key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4309 + $per_page = (int) $_GET[$per_page_key]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4310 + if (in_array($per_page, Metasync_Per_Page_Helper::allowed_values(), true)) {
4311 + $query_parts[] = $per_page_key . '=' . $per_page;
4312 + }
4313 + }
4314 +
3792 4315 return !empty($query_parts) ? '&' . implode('&', $query_parts) : '';
3793 4316 }
3794 4317
3795 4318 /**
@@ -3963,18 +4486,8 @@
3963 4486 {
3964 4487 Metasync_Connect_Manager::instance()->handle_session_management_early();
3965 4488 }
3966 4489
3967 - /**
3968 - * @deprecated 2.5.12 Use Metasync_Auth_Manager instead of sessions for authentication
3969 - */
3970 - private function safe_session_start() {
3971 - // This method is deprecated and no longer used
3972 - // Authentication now uses Metasync_Auth_Manager with WordPress transients and user meta
3973 - _deprecated_function(__METHOD__, '2.5.12', 'Metasync_Auth_Manager');
3974 - return Metasync_Session_Helper::safe_start();
3975 - }
3976 -
3977 4490 private function handle_whitelabel_session_logic()
3978 4491 {
3979 4492 Metasync_Connect_Manager::instance()->handle_whitelabel_session_logic();
3980 4493 }
@@ -4020,8 +4533,9 @@
4020 4533 $stats = Metasync_CPU_Monitor::get_stats();
4021 4534 $per_core_threshold = Metasync_CPU_Monitor::get_per_core_threshold();
4022 4535 $cores = Metasync_CPU_Monitor::get_cpu_core_count();
4023 4536 $effective_threshold = Metasync_CPU_Monitor::get_effective_threshold();
4537 + $detection_reliable = Metasync_CPU_Monitor::is_core_detection_reliable();
4024 4538 ?>
4025 4539 <div style="background: var(--dashboard-card-bg); padding: 20px; border-radius: 8px;">
4026 4540 <!-- CPU Cores Detected -->
4027 4541 <div style="margin-bottom: 24px;">
@@ -4028,12 +4542,20 @@
4028 4542 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4029 4543 CPU Cores Detected
4030 4544 </label>
4031 4545 <div style="padding: 10px 12px; background: var(--dashboard-input-bg); border: 1px solid var(--dashboard-border); border-radius: 6px; color: var(--dashboard-text-secondary);">
4032 - <strong><?php echo intval($cores); ?></strong> core<?php echo $cores !== 1 ? 's' : ''; ?>
4546 + <?php if ($detection_reliable) : ?>
4547 + <strong><?php echo intval($cores); ?></strong> core<?php echo $cores !== 1 ? 's' : ''; ?>
4548 + <?php else : ?>
4549 + <strong>Not detected</strong>
4550 + <?php endif; ?>
4033 4551 </div>
4034 4552 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
4035 - Automatically detected on this system.
4553 + <?php if ($detection_reliable) : ?>
4554 + Automatically detected on this system.
4555 + <?php else : ?>
4556 + Core detection is not available on this hosting environment.
4557 + <?php endif; ?>
4036 4558 </p>
4037 4559 </div>
4038 4560
4039 4561 <!-- Per-Core Load Threshold -->
@@ -4039,8 +4561,9 @@
4039 4561 <!-- Per-Core Load Threshold -->
4040 4562 <div style="margin-bottom: 24px;">
4041 4563 <label for="cpu_load_per_core_threshold" style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4042 4564 Per-Core Load Threshold
4565 + <?php Metasync::render_tooltip_icon('cpu_per_core_load_threshold', 'How busy each CPU core can get before the plugin pauses background SEO syncing. Lower = more cautious. Most sites leave this at 2.0.'); ?>
4043 4566 </label>
4044 4567 <input type="number"
4045 4568 id="cpu_load_per_core_threshold"
4046 4569 name="metasync_options[performance][cpu_load_per_core_threshold]"
@@ -4060,9 +4583,9 @@
4060 4583 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
4061 4584 Effective Threshold
4062 4585 </label>
4063 4586 <div style="padding: 10px 12px; background: var(--dashboard-input-bg); border: 1px solid var(--dashboard-border); border-radius: 6px; color: var(--dashboard-text-secondary);">
4064 - <strong id="effective_threshold_value"><?php echo round($effective_threshold, 2); ?></strong>
4587 + <strong id="effective_threshold_value"><?php echo esc_html((string) round($effective_threshold, 2)); ?></strong>
4065 4588 </div>
4066 4589 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
4067 4590 Calculated as: cores × per-core threshold
4068 4591 </p>
@@ -4080,13 +4603,13 @@
4080 4603 <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo intval($stats['deferrals']); ?></div>
4081 4604 </div>
4082 4605 <div>
4083 4606 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Max Load Observed</div>
4084 - <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo round($stats['max_load'], 2); ?></div>
4607 + <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo esc_html((string) round($stats['max_load'], 2)); ?></div>
4085 4608 </div>
4086 4609 <div>
4087 4610 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Average Load</div>
4088 - <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo round($stats['avg_load'], 2); ?></div>
4611 + <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo esc_html((string) round($stats['avg_load'], 2)); ?></div>
4089 4612 </div>
4090 4613 </div>
4091 4614 </div>
4092 4615
@@ -4592,88 +5115,8 @@
4592 5115
4593 5116 /**
4594 5117 * Get the settings option array and print one of its values
4595 5118 */
4596 - public function no_index_posts_callback()
4597 - {
4598 - Metasync_Settings_Fields::instance()->no_index_posts_callback();
4599 - }
4600 -
4601 - /**
4602 - * Get the settings option array and print one of its values
4603 - */
4604 - public function no_follow_links_callback()
4605 - {
4606 - Metasync_Settings_Fields::instance()->no_follow_links_callback();
4607 - }
4608 -
4609 - /**
4610 - * Get the settings option array and print one of its values
4611 - */
4612 - public function open_external_links_callback()
4613 - {
4614 - Metasync_Settings_Fields::instance()->open_external_links_callback();
4615 - }
4616 -
4617 - /**
4618 - * Get the settings option array and print one of its values
4619 - */
4620 - public function add_alt_image_tags_callback()
4621 - {
4622 - Metasync_Settings_Fields::instance()->add_alt_image_tags_callback();
4623 - }
4624 -
4625 - /**
4626 - * Get the settings option array and print one of its values
4627 - */
4628 - public function add_title_image_tags_callback()
4629 - {
4630 - Metasync_Settings_Fields::instance()->add_title_image_tags_callback();
4631 - }
4632 -
4633 - /**
4634 - * Get the settings option array and print one of its values
4635 - */
4636 - public function site_type_callback()
4637 - {
4638 - Metasync_Settings_Fields::instance()->site_type_callback();
4639 - }
4640 -
4641 - /**
4642 - * Get the settings option array and print one of its values
4643 - */
4644 - public function site_business_type_callback()
4645 - {
4646 - Metasync_Settings_Fields::instance()->site_business_type_callback();
4647 - }
4648 -
4649 - /**
4650 - * Get the settings option array and print one of its values
4651 - */
4652 - public function site_company_name_callback()
4653 - {
4654 - Metasync_Settings_Fields::instance()->site_company_name_callback();
4655 - }
4656 -
4657 - /**
4658 - * Get the settings option array and print one of its values
4659 - */
4660 - public function site_google_logo_callback()
4661 - {
4662 - Metasync_Settings_Fields::instance()->site_google_logo_callback();
4663 - }
4664 -
4665 - /**
4666 - * Get the settings option array and print one of its values
4667 - */
4668 - public function site_social_share_image_callback()
4669 - {
4670 - Metasync_Settings_Fields::instance()->site_social_share_image_callback();
4671 - }
4672 -
4673 - /**
4674 - * Get the settings option array and print one of its values
4675 - */
4676 5119 public function common_robot_meta_tags_callback()
4677 5120 {
4678 5121 Metasync_Settings_Fields::instance()->common_robot_meta_tags_callback();
4679 5122 }
@@ -4871,8 +5314,14 @@
4871 5314 * Show a one-time admin notice when a page builder is detected but the
4872 5315 * "Default Page Builder" setting has never been explicitly saved.
4873 5316 */
4874 5317 public function display_page_builder_notice() {
5318 + // Only relevant on the plugin's own settings page — avoid repeating
5319 + // this notice across every admin screen.
5320 + if (!isset($_GET['page']) || wp_unslash($_GET['page']) !== self::$page_slug) {
5321 + return;
5322 + }
5323 +
4875 5324 $configured = Metasync::get_option('general')['default_page_builder'] ?? '';
4876 5325
4877 5326 // Setting already saved — nothing to warn about
4878 5327 if (!empty($configured)) {
@@ -4885,9 +5334,9 @@
4885 5334 return;
4886 5335 }
4887 5336
4888 5337 // Handle dismiss action
4889 - if (isset($_GET['metasync_dismiss_builder_notice']) && wp_verify_nonce($_GET['_wpnonce'] ?? '', 'metasync_dismiss_builder')) {
5338 + if (isset($_GET['metasync_dismiss_builder_notice'], $_GET['_wpnonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'metasync_dismiss_builder')) {
4890 5339 update_user_meta(get_current_user_id(), 'metasync_builder_notice_dismissed', '1');
4891 5340 return;
4892 5341 }
4893 5342
@@ -4982,9 +5431,10 @@
4982 5431 * Saves the CPU load threshold and returns statistics
4983 5432 */
4984 5433 public function ajax_save_performance_settings() {
4985 5434 # Check nonce for security and return early if invalid
4986 - if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce($_POST['meta_sync_nonce'], 'meta_sync_general_setting_nonce')) {
5435 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5436 + if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce(wp_unslash($_POST['meta_sync_nonce']), 'meta_sync_general_setting_nonce')) {
4987 5437 wp_send_json_error(array('message' => 'Invalid nonce'));
4988 5438 return;
4989 5439 }
4990 5440
@@ -5052,9 +5502,9 @@
5052 5502 if (!wp_next_scheduled('metasync_cleanup_transients')) {
5053 5503 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_cleanup_transients');
5054 5504
5055 5505 if (!$scheduled) {
5056 - error_log('MetaSync: Failed to schedule transient cleanup cron job');
5506 + error_log('MetaSync: Failed to schedule transient cleanup cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5057 5507 }
5058 5508 }
5059 5509 }
5060 5510
@@ -5065,9 +5515,9 @@
5065 5515 {
5066 5516 $timestamp = wp_next_scheduled('metasync_cleanup_transients');
5067 5517 if ($timestamp) {
5068 5518 wp_unschedule_event($timestamp, 'metasync_cleanup_transients');
5069 - error_log('MetaSync: Transient cleanup cron job unscheduled');
5519 + error_log('MetaSync: Transient cleanup cron job unscheduled'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- unreachable in production
5070 5520 }
5071 5521 }
5072 5522
5073 5523 /**
@@ -5089,12 +5539,10 @@
5089 5539 {
5090 5540 if (!wp_next_scheduled('metasync_hidden_post_check')) {
5091 5541 $scheduled = wp_schedule_event(time(), 'metasync_weekly', 'metasync_hidden_post_check');
5092 5542
5093 - if ($scheduled) {
5094 - error_log('MetaSync: Hidden post manager cron job scheduled successfully (runs every 7 days)');
5095 - } else {
5096 - error_log('MetaSync: Failed to schedule hidden post manager cron job');
5543 + if (!$scheduled) {
5544 + error_log('MetaSync: Failed to schedule hidden post manager cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5097 5545 }
5098 5546 }
5099 5547 }
5100 5548
@@ -5105,12 +5553,10 @@
5105 5553 public function maybe_schedule_otto_recheck_404_cron()
5106 5554 {
5107 5555 if (!wp_next_scheduled('metasync_otto_recheck_404_exclusions')) {
5108 5556 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_otto_recheck_404_exclusions');
5109 - if ($scheduled) {
5110 - error_log('MetaSync: OTTO 404 recheck cron job scheduled successfully (runs daily)');
5111 - } else {
5112 - error_log('MetaSync: Failed to schedule OTTO 404 recheck cron job');
5557 + if (!$scheduled) {
5558 + error_log('MetaSync: Failed to schedule OTTO 404 recheck cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5113 5559 }
5114 5560 }
5115 5561 }
5116 5562
@@ -5183,13 +5629,13 @@
5183 5629
5184 5630 try {
5185 5631 // 1. Post revisions
5186 5632 if (!empty($settings['clean_post_revisions'])) {
5187 - $stats['post_revisions'] = (int) $wpdb->query(
5633 + $stats['post_revisions'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5188 5634 "DELETE FROM {$wpdb->posts} WHERE post_type = 'revision'"
5189 5635 );
5190 5636 // Remove postmeta left behind by deleted revisions
5191 - $wpdb->query(
5637 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5192 5638 "DELETE pm FROM {$wpdb->postmeta} pm
5193 5639 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5194 5640 WHERE p.ID IS NULL"
5195 5641 );
@@ -5197,20 +5643,19 @@
5197 5643
5198 5644 // 2. Trashed posts + their postmeta
5199 5645 if (!empty($settings['clean_trashed_posts'])) {
5200 5646 // Collect IDs first to cleanly remove postmeta
5201 - $trashed_ids = $wpdb->get_col(
5647 + $trashed_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5202 5648 "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'trash'"
5203 5649 );
5204 5650 if (!empty($trashed_ids)) {
5205 - $placeholders = implode(',', array_fill(0, count($trashed_ids), '%d'));
5206 - $wpdb->query(
5651 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5207 5652 $wpdb->prepare(
5208 - "DELETE FROM {$wpdb->postmeta} WHERE post_id IN ($placeholders)",
5653 + 'DELETE FROM ' . $wpdb->postmeta . ' WHERE post_id IN (' . implode(',', array_fill(0, count($trashed_ids), '%d')) . ')',
5209 5654 $trashed_ids
5210 5655 )
5211 5656 );
5212 - $stats['trashed_posts'] = (int) $wpdb->query(
5657 + $stats['trashed_posts'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5213 5658 "DELETE FROM {$wpdb->posts} WHERE post_status = 'trash'"
5214 5659 );
5215 5660 } else {
5216 5661 $stats['trashed_posts'] = 0;
@@ -5218,9 +5663,9 @@
5218 5663 }
5219 5664
5220 5665 // 3. Trashed comments
5221 5666 if (!empty($settings['clean_trashed_comments'])) {
5222 - $stats['trashed_comments'] = (int) $wpdb->query(
5667 + $stats['trashed_comments'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5223 5668 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'trash'"
5224 5669 );
5225 5670 }
5226 5671
@@ -5225,9 +5670,9 @@
5225 5670 }
5226 5671
5227 5672 // 4. Spam comments
5228 5673 if (!empty($settings['clean_spam_comments'])) {
5229 - $stats['spam_comments'] = (int) $wpdb->query(
5674 + $stats['spam_comments'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5230 5675 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'spam'"
5231 5676 );
5232 5677 }
5233 5678
@@ -5233,15 +5678,15 @@
5233 5678
5234 5679 // 5. Expired transients — direct SQL, no cache flush
5235 5680 if (!empty($settings['clean_expired_transients'])) {
5236 5681 // Delete timeout rows that have already expired
5237 - $wpdb->query(
5682 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5238 5683 "DELETE FROM {$wpdb->options}
5239 5684 WHERE option_name LIKE '\_transient\_timeout\_%'
5240 5685 AND option_value + 0 < UNIX_TIMESTAMP()"
5241 5686 );
5242 5687 // Delete value rows whose timeout row no longer exists
5243 - $stats['expired_transients'] = (int) $wpdb->query(
5688 + $stats['expired_transients'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5244 5689 "DELETE o FROM {$wpdb->options} o
5245 5690 LEFT JOIN {$wpdb->options} t
5246 5691 ON t.option_name = CONCAT('_transient_timeout_', SUBSTRING(o.option_name, 12))
5247 5692 WHERE o.option_name LIKE '\_transient\_%'
@@ -5251,9 +5696,9 @@
5251 5696 }
5252 5697
5253 5698 // 6. Orphaned postmeta (post_id references a post that no longer exists)
5254 5699 if (!empty($settings['clean_orphaned_postmeta'])) {
5255 - $stats['orphaned_postmeta'] = (int) $wpdb->query(
5700 + $stats['orphaned_postmeta'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5256 5701 "DELETE pm FROM {$wpdb->postmeta} pm
5257 5702 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5258 5703 WHERE p.ID IS NULL"
5259 5704 );
@@ -5265,12 +5710,10 @@
5265 5710 $settings['last_run_at'] = time();
5266 5711 $settings['last_run_stats'] = $stats;
5267 5712 update_option('metasync_db_cleanup_settings', $settings);
5268 5713
5269 - error_log('MetaSync: DB cleanup completed — ' . json_encode($stats));
5270 -
5271 5714 } catch (Exception $e) {
5272 - error_log('MetaSync: DB cleanup failed — ' . $e->getMessage());
5715 + error_log('MetaSync: DB cleanup failed — ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5273 5716 }
5274 5717 }
5275 5718
5276 5719 /**
@@ -5487,10 +5930,11 @@
5487 5930 /**
5488 5931 * AJAX: Save DB cleanup settings and reschedule cron accordingly.
5489 5932 */
5490 5933 public function ajax_save_db_cleanup_settings() {
5934 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5491 5935 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5492 - !wp_verify_nonce($_POST['db_cleanup_settings_nonce'], 'metasync_db_cleanup_settings_nonce')) {
5936 + !wp_verify_nonce(wp_unslash($_POST['db_cleanup_settings_nonce']), 'metasync_db_cleanup_settings_nonce')) {
5493 5937 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
5494 5938 return;
5495 5939 }
5496 5940
@@ -5547,10 +5991,11 @@
5547 5991 * AJAX: Manually trigger the DB cleanup and return stats for the UI.
5548 5992 * Persists current form state first so unsaved checkbox changes are respected.
5549 5993 */
5550 5994 public function ajax_run_db_cleanup() {
5995 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5551 5996 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5552 - !wp_verify_nonce($_POST['db_cleanup_settings_nonce'], 'metasync_db_cleanup_settings_nonce')) {
5997 + !wp_verify_nonce(wp_unslash($_POST['db_cleanup_settings_nonce']), 'metasync_db_cleanup_settings_nonce')) {
5553 5998 wp_send_json_error(array('message' => 'Invalid security token.'));
5554 5999 return;
5555 6000 }
5556 6001
@@ -5626,35 +6071,8 @@
5626 6071 ));
5627 6072 }
5628 6073
5629 6074 /**
5630 - * Control plugin auto-updates based on user setting
5631 - *
5632 - * @param bool $update Whether to update
5633 - * @param object $item Update offer
5634 - * @return bool Whether to allow auto-update
5635 - */
5636 - public function control_plugin_auto_updates($update, $item)
5637 - {
5638 - # Check if the item object has the slug property
5639 - if (!isset($item->slug)) {
5640 - return $update;
5641 - }
5642 -
5643 - // Check if this is our plugin
5644 - if ($item->slug === 'metasync') {
5645 - $general_settings = Metasync::get_option('general') ?? [];
5646 - $enable_auto_updates = $general_settings['enable_auto_updates'] ?? false;
5647 -
5648 - // Return the user's preference (true = allow auto-updates, false = prevent)
5649 - return $enable_auto_updates === 'true' || $enable_auto_updates === true;
5650 - }
5651 -
5652 - // For other plugins, don't interfere with their auto-update settings
5653 - return $update;
5654 - }
5655 -
5656 - /**
5657 6075 * AJAX handler to add excluded URL for OTTO
5658 6076 */
5659 6077 public function ajax_otto_add_excluded_url()
5660 6078 {
@@ -5800,8 +6218,9 @@
5800 6218 // If page has raw HTML or custom CSS from conversion, show badge
5801 6219 if ($has_raw_html || !empty($has_custom_css)) {
5802 6220 $label = $this->get_html_source_label();
5803 6221 $tooltip = sprintf(
6222 + /* translators: %s: HTML source label. */
5804 6223 __('This page was created using %s HTML-to-Builder converter', 'metasync'),
5805 6224 $label
5806 6225 );
5807 6226
@@ -5840,8 +6259,16 @@
5840 6259 if (!$post || !in_array($post->post_type, array('post', 'page'))) {
5841 6260 return;
5842 6261 }
5843 6262
6263 + // Don't show the "HTML-to-Builder converter" banner on LPS-synced / custom-HTML
6264 + // pages: those are raw-HTML store-and-serve pages, NOT actually converted to a
6265 + // page builder, so the banner mislabels them. It still shows for pages genuinely
6266 + // produced by the converter.
6267 + if (function_exists('metasync_is_custom_or_lps_page') && metasync_is_custom_or_lps_page($post->ID)) {
6268 + return;
6269 + }
6270 +
5844 6271 $has_raw_html = get_post_meta($post->ID, '_metasync_raw_html_enabled', true);
5845 6272 $has_custom_css = get_post_meta($post->ID, '_metasync_custom_css', true);
5846 6273
5847 6274 if ($has_raw_html || !empty($has_custom_css)) {
@@ -5846,8 +6273,9 @@
5846 6273
5847 6274 if ($has_raw_html || !empty($has_custom_css)) {
5848 6275 $label = $this->get_html_source_label();
5849 6276 $message = sprintf(
6277 + /* translators: %s: HTML source label. */
5850 6278 __('This page was created using %s HTML-to-Builder converter. The design is preserved with custom CSS and inline styles.', 'metasync'),
5851 6279 '<strong>' . esc_html($label) . '</strong>'
5852 6280 );
5853 6281
@@ -5861,9 +6289,9 @@
5861 6289 <p class="metasync-editor-message">%s</p>
5862 6290 </div>
5863 6291 </div>',
5864 6292 esc_html($label),
5865 - $message
6293 + wp_kses($message, array('strong' => array()))
5866 6294 );
5867 6295 }
5868 6296 }
5869 6297
@@ -5886,9 +6314,9 @@
5886 6314 ?>
5887 6315 <fieldset class="inline-edit-col-left metasync-quick-edit-source">
5888 6316 <div class="inline-edit-col">
5889 6317 <label>
5890 - <span class="title"><?php _e('Source', 'metasync'); ?></span>
6318 + <span class="title"><?php esc_html_e('Source', 'metasync'); ?></span>
5891 6319 <span class="metasync-quick-edit-badge-container"></span>
5892 6320 </label>
5893 6321 </div>
5894 6322 </fieldset>
@@ -5900,8 +6328,9 @@
5900 6328 */
5901 6329 public function add_html_pages_dashboard_widget()
5902 6330 {
5903 6331 $label = $this->get_html_source_label();
6332 + /* translators: %s: HTML source label. */
5904 6333 $widget_title = sprintf(__('%s Pages', 'metasync'), $label);
5905 6334
5906 6335 wp_add_dashboard_widget(
5907 6336 'metasync_html_pages_widget',
@@ -5957,8 +6386,14 @@
5957 6386 * @return void Sends JSON response and exits
5958 6387 */
5959 6388 public function ajax_send_giapi()
5960 6389 {
6390 + check_ajax_referer('metasync_nonce', 'nonce');
6391 +
6392 + if (!Metasync::current_user_has_plugin_access()) {
6393 + wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
6394 + }
6395 +
5961 6396 $post_data = metasync_sanitize_input_array($_POST);
5962 6397 if (!isset($post_data['metasync_giapi_url'])) {
5963 6398 return;
5964 6399 }
@@ -6024,8 +6459,14 @@
6024 6459 * @return void Sends JSON response and exits
6025 6460 */
6026 6461 public function ajax_send_bing_indexnow()
6027 6462 {
6463 + check_ajax_referer('metasync_nonce', 'nonce');
6464 +
6465 + if (!Metasync::current_user_has_plugin_access()) {
6466 + wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
6467 + }
6468 +
6028 6469 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
6029 6470 $bing_instant_index = new Metasync_Bing_Instant_Index();
6030 6471 $bing_instant_index->send();
6031 6472 }
@@ -6042,8 +6483,18 @@
6042 6483 if (!isset($_POST['submit'])) {
6043 6484 return;
6044 6485 }
6045 6486
6487 + // This handler runs on admin_init, which also fires inside
6488 + // admin-ajax.php before its login gate, so a bare isset() check let
6489 + // any request (including logged-out ones) rewrite the auto-submit
6490 + // post types. Require the nonce and plugin access before writing.
6491 + if (!isset($_POST['metasync_instant_indexing_nonce'])
6492 + || !wp_verify_nonce(sanitize_key(wp_unslash($_POST['metasync_instant_indexing_nonce'])), 'metasync_instant_indexing_settings')
6493 + || !Metasync::current_user_has_plugin_access()) {
6494 + return;
6495 + }
6496 +
6046 6497 // Save post types for Google Instant Indexing auto-submit
6047 6498 if (isset($_POST['metasync_post_types'])) {
6048 6499 $post_data = metasync_sanitize_input_array($_POST);
6049 6500 $post_types = is_array($post_data['metasync_post_types']) ? array_map('sanitize_title', $post_data['metasync_post_types']) : [];
@@ -6127,12 +6578,24 @@
6127 6578 if (!empty($seo_controls['enable_googleinstantindex']) && $seo_controls['enable_googleinstantindex'] === 'true') {
6128 6579 $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
6129 6580 $post_types = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
6130 6581
6131 - if (in_array($post->post_type, $post_types) && function_exists('google_index_direct')) {
6132 - $service_info = google_index_direct()->get_service_account_info();
6133 - if (!isset($service_info['error'])) {
6134 - google_index_direct()->index_post($post_id, $post->post_type, 'update');
6582 + if (in_array($post->post_type, $post_types)) {
6583 + // save_post is the single owner of auto-submit and also fires
6584 + // on REST-created posts, where nothing else may have loaded
6585 + // the Google Index helpers yet — load them on demand instead
6586 + // of silently skipping.
6587 + if (!function_exists('google_index_direct')) {
6588 + $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
6589 + if (file_exists($google_index_path)) {
6590 + require_once $google_index_path;
6591 + }
6592 + }
6593 + if (function_exists('google_index_direct')) {
6594 + $service_info = google_index_direct()->get_service_account_info();
6595 + if (!isset($service_info['error'])) {
6596 + google_index_direct()->index_post($post_id, $post->post_type, 'update');
6597 + }
6135 6598 }
6136 6599 }
6137 6600 }
6138 6601