PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | admin/class-metasync-admin.php +1228 -582 2.6.5 → 2.7.2 View file →
@@ -33,9 +33,8 @@
33 33 const SECTION_METASYNC = "metasync_settings";
34 34 const SECTION_SEARCHENGINE = "searchengine_settings";
35 35 const SECTION_LOCALSEO = "local_seo";
36 36 const SECTION_CODESNIPPETS = "code_snippets";
37 - const SECTION_OPTIMAL_SETTINGS = "optimal_settings";
38 37 const SECTION_SITE_SETTINGS = "site_settings";
39 38 const SECTION_COMMON_SETTINGS = "common_settings";
40 39 const SECTION_COMMON_META_SETTINGS = "common_meta_settings";
41 40 const SECTION_SOCIAL_META = "social_meta";
@@ -42,9 +41,8 @@
42 41 const SECTION_SEO_CONTROLS = "seo_controls";
43 42 const SECTION_SEO_CONTROLS_ADVANCED = "seo_controls_advanced";
44 43 const SECTION_SEO_CONTROLS_INSTANT_INDEX = "seo_controls_instant_index";
45 44 const SECTION_PLUGIN_VISIBILITY = "plugin_visibility_settings";
46 - const SECTION_BREADCRUMBS = "breadcrumbs_settings";
47 45 const SECTION_LLMS_TXT = "llms_txt_settings";
48 46
49 47 /**
50 48 * The ID of this plugin.
@@ -198,13 +196,18 @@
198 196 $data = Metasync::get_option('general');
199 197
200 198 // Set menu title using the effective title (includes whitelabel company name if available)
201 199 $this->menu_title = $this->get_effective_menu_title();
202 - if(!isset( $data['white_label_plugin_menu_slug'])){
203 - self::$page_slug = "searchatlas";
204 - }else{
205 - self::$page_slug = $data['white_label_plugin_menu_slug']=="" ? "searchatlas":$data['white_label_plugin_menu_slug'];
206 - }
200 + $raw_slug = isset($data['white_label_plugin_menu_slug']) ? $data['white_label_plugin_menu_slug'] : '';
201 + $clean_slug = sanitize_title($raw_slug);
202 + # Self-heal a legacy URL-shaped slug: persist the sanitized value so every
203 + # reader that builds admin links from the stored option gets a valid WP menu slug.
204 + if ($raw_slug !== '' && $clean_slug !== $raw_slug) {
205 + $options = Metasync::get_option();
206 + $options['general']['white_label_plugin_menu_slug'] = $clean_slug;
207 + Metasync::set_option($options);
208 + }
209 + self::$page_slug = $clean_slug === '' ? 'searchatlas' : $clean_slug;
207 210
208 211 add_action('admin_menu', array($this, 'add_plugin_settings_page'));
209 212 add_action('admin_menu', array($this, 'add_import_external_data_page'));
210 213 add_action('admin_init', array($this, 'settings_page_init'));
@@ -209,9 +212,8 @@
209 212 add_action('admin_menu', array($this, 'add_import_external_data_page'));
210 213 add_action('admin_init', array($this, 'settings_page_init'));
211 214 add_filter('all_plugins', array($this,'metasync_plugin_white_label'));
212 215 add_filter( 'plugin_row_meta',array($this,'metasync_view_detials_url'),10,3);
213 - add_filter('site_transient_update_plugins', array($this, 'inject_whitelabel_icon_into_update_transient'));
214 216
215 217 // Display transient error/success messages for redirections
216 218 add_action('admin_notices', array($this, 'display_redirection_messages'));
217 219
@@ -243,8 +245,9 @@
243 245 #add css into admin header for icon image
244 246
245 247 add_action('admin_head', array($this,'metasync_admin_icon_style'));
246 248 add_action('admin_head', array($this, 'metasync_fouc_prevention_style'));
249 + add_action('admin_head', array($this, 'suppress_notices_on_wizard_page'), 1);
247 250
248 251 // Always add admin bar styles - the method will check the setting internally
249 252 add_action('wp_head', array($this,'metasync_admin_bar_style')); // For frontend admin bar
250 253 add_action('admin_head', array($this,'metasync_admin_bar_style')); // For backend admin bar
@@ -253,11 +256,22 @@
253 256
254 257 // Sync plugin file headers whenever metasync_options is updated (covers AJAX save, Settings API, import)
255 258 add_action('update_option_metasync_options', array($this, 'on_options_updated_sync_file_headers'), 10, 2);
256 259
260 + // Invalidate the admin bar status cache when settings change or the API key is rotated.
261 + add_action('update_option_metasync_options', array('Metasync_Admin_Navigation', 'invalidate_admin_bar_status_cache'), 10, 0);
262 + add_action('metasync_api_key_changed', array('Metasync_Admin_Navigation', 'invalidate_admin_bar_status_cache'), 10, 0);
263 +
257 264 add_action('admin_init', array($this, 'initialize_cookie'));
258 265 add_action('admin_init', array($this, 'maybe_redirect_to_wizard'));
259 266
267 + // The redirect must run on admin_menu, not admin_init: in current
268 + // WordPress the unregistered-page access check in wp-admin/includes/
269 + // menu.php fires from wp-admin/menu.php (required at admin.php:163),
270 + // which is BEFORE admin_init (admin.php:180). Hooked on admin_init the
271 + // redirect never ran — WP died with a 403 first for the removed slug.
272 + add_action('admin_menu', array($this, 'redirect_legacy_instant_indexing_page'));
273 +
260 274 // Add admin_post hooks for form submissions (WordPress standard way - no output buffering needed)
261 275 add_action('admin_post_metasync_clear_all_cache_plugins', array($this, 'handle_clear_all_cache_plugins'));
262 276 add_action('admin_post_metasync_clear_otto_cache_all', array($this, 'handle_clear_otto_cache_all'));
263 277 add_action('admin_post_metasync_clear_otto_cache_url', array($this, 'handle_clear_otto_cache_url'));
@@ -274,10 +288,16 @@
274 288
275 289 // Add AJAX for saving execution settings
276 290 add_action( 'wp_ajax_metasync_save_execution_settings', array($this, 'ajax_save_execution_settings') );
277 291
292 + // Add AJAX for saving the Headless Mode demo controls on the Advanced tab
293 + add_action( 'wp_ajax_metasync_save_headless_settings', array(Metasync_Settings_Registration::instance(), 'ajax_save_headless_settings') );
294 +
278 295 // Add AJAX for saving hosting cache settings
279 296 add_action( 'wp_ajax_metasync_save_hosting_cache_settings', array($this, 'ajax_save_hosting_cache_settings') );
297 +
298 + // Add AJAX for saving OTTO Cache TTL
299 + add_action('wp_ajax_metasync_save_otto_cache_ttl', array($this, 'ajax_save_otto_cache_ttl'));
280 300 add_action( 'wp_ajax_metasync_save_object_cache_settings', array($this, 'ajax_save_object_cache_settings') );
281 301
282 302 // Add AJAX for saving edge cache / CDN settings
283 303 add_action( 'wp_ajax_metasync_save_edge_cache_settings', array('Metasync_Edge_Cache_Settings', 'ajax_save') );
@@ -302,8 +322,9 @@
302 322 add_action('wp_ajax_metasync_get_default_robots', array($this, 'ajax_get_default_robots'));
303 323 add_action('wp_ajax_metasync_preview_robots_backup', array($this, 'ajax_preview_robots_backup'));
304 324 add_action('wp_ajax_metasync_delete_robots_backup', array($this, 'ajax_delete_robots_backup'));
305 325 add_action('wp_ajax_metasync_restore_robots_backup', array($this, 'ajax_restore_robots_backup'));
326 + add_action('wp_ajax_metasync_get_robots_backups', array($this, 'ajax_get_robots_backups'));
306 327
307 328 // Add AJAX handlers for host blocking test
308 329 add_action('wp_ajax_metasync_test_host_blocking_get', array($this, 'ajax_test_host_blocking_get'));
309 330 add_action('wp_ajax_metasync_test_host_blocking_post', array($this, 'ajax_test_host_blocking_post'));
@@ -355,11 +376,20 @@
355 376 add_action('wp_ajax_metasync_start_batch_optimize', array($this, 'ajax_start_batch_optimize'));
356 377 add_action('wp_ajax_metasync_cancel_batch_optimize', array($this, 'ajax_cancel_batch_optimize'));
357 378 add_action('wp_ajax_metasync_batch_progress', array($this, 'ajax_batch_progress'));
358 379 add_action('wp_ajax_metasync_bulk_optimize_selected', array($this, 'ajax_bulk_optimize_selected'));
380 + add_action('wp_ajax_metasync_bulk_unoptimize_selected', array($this, 'ajax_bulk_unoptimize_selected'));
359 381 add_action('wp_ajax_metasync_process_batch_tick', array($this, 'ajax_process_batch_tick'));
382 + add_action('wp_ajax_metasync_delete_orphaned_image', array($this, 'ajax_delete_orphaned_image'));
360 383 add_action('metasync_media_batch_optimize_cron', array($this, 'handle_media_batch_cron'));
361 384
385 + # SEO Restore (Rollback) AJAX & Cron handlers
386 + add_action('wp_ajax_metasync_seo_restore_start', array($this, 'ajax_seo_restore_start'));
387 + add_action('wp_ajax_metasync_seo_restore_cancel', array($this, 'ajax_seo_restore_cancel'));
388 + add_action('wp_ajax_metasync_seo_restore_progress', array($this, 'ajax_seo_restore_progress'));
389 + add_action('wp_ajax_metasync_seo_restore_process_tick', array($this, 'ajax_seo_restore_process_tick'));
390 + add_action('metasync_seo_restore_cron', array($this, 'handle_seo_restore_cron'));
391 +
362 392 # Add AJAX handlers for Google Instant Indexing
363 393 add_action('wp_ajax_metasync_send_giapi', array($this, 'ajax_send_giapi'));
364 394
365 395 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
@@ -364,11 +394,8 @@
364 394
365 395 # Add AJAX handlers for Bing Instant Indexing (IndexNow)
366 396 add_action('wp_ajax_metasync_send_bing_indexnow', array($this, 'ajax_send_bing_indexnow'));
367 397
368 - # Add hooks for instant indexing settings saves
369 - add_action('admin_init', array($this, 'save_instant_indexing_settings'));
370 -
371 398 # Add hooks for instant indexing post actions
372 399 add_filter('post_row_actions', array($this, 'add_instant_indexing_post_actions'), 10, 2);
373 400 add_filter('page_row_actions', array($this, 'add_instant_indexing_post_actions'), 10, 2);
374 401
@@ -452,9 +479,11 @@
452 479
453 480 $data= Metasync::get_option('general');
454 481
455 482 # Get white label menu slug
456 - $menu_slug = empty($data['white_label_plugin_menu_slug']) ? self::$page_slug : $data['white_label_plugin_menu_slug'];
483 + # Sanitize so a URL-shaped legacy value still yields a valid WP menu slug
484 + $menu_slug = empty($data['white_label_plugin_menu_slug']) ? self::$page_slug : sanitize_title($data['white_label_plugin_menu_slug']);
485 + $menu_slug = $menu_slug === '' ? self::$page_slug : $menu_slug;
457 486
458 487 ?>
459 488 <style>
460 489 #toplevel_page_<?php echo esc_attr(str_replace(' ', '-',$menu_slug)); ?> .wp-menu-image.dashicons-before img {
@@ -471,16 +500,129 @@
471 500 <?php
472 501 }
473 502
474 503 public function metasync_fouc_prevention_style() {
475 - if ( ! isset( $_GET['page'] ) || strpos( $_GET['page'], self::$page_slug ) !== 0 ) {
504 + if ( ! isset( $_GET['page'] ) || strpos( sanitize_key(wp_unslash($_GET['page'])), self::$page_slug ) !== 0 ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check for an inline style
476 505 return;
477 506 }
478 507 ?>
479 - <style>.metasync-dashboard-wrap { opacity: 0; transition: opacity 0.15s ease-in; }</style>
508 + <style>
509 + .metasync-dashboard-wrap { opacity: 0; transition: opacity 0.15s ease-in; }
510 + /*
511 + * WP prints admin notices at the top of #wpbody-content, then core's
512 + * common.js relocates them next to .wp-header-end inside our wrap on
513 + * DOM-ready. Keep them out of the flow until that move so they don't
514 + * flash above the dashboard header and shove the page down.
515 + * Once relocated they're no longer a direct child here, so this rule
516 + * stops matching and they fade in with the wrap. We mirror core's
517 + * relocation set exactly (.notice/.updated/.error, minus .inline and
518 + * .below-h2); .update-nag is left alone since core never moves it.
519 + */
520 + #wpbody-content > .notice:not(.inline):not(.below-h2),
521 + #wpbody-content > .updated:not(.inline):not(.below-h2),
522 + #wpbody-content > .error:not(.inline):not(.below-h2) { display: none !important; }
523 + </style>
480 524 <?php
481 525 }
482 526
527 + public function suppress_notices_on_wizard_page() {
528 + if ( ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
529 + return;
530 + }
531 +
532 + $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
533 +
534 + // Setup wizard: strip every notice for a fully focused, distraction-free screen.
535 + if ( strpos( $page, '-setup-wizard' ) !== false ) {
536 + remove_all_actions( 'admin_notices' );
537 + remove_all_actions( 'all_admin_notices' );
538 + return;
539 + }
540 +
541 + // Other Search Atlas plugin pages: strip third-party notices but keep our own.
542 + if ( strpos( $page, self::$page_slug ) === 0 ) {
543 + $this->remove_third_party_admin_notices();
544 + }
545 + }
546 +
547 + /**
548 + * Remove admin-notice callbacks that don't belong to this plugin.
549 + *
550 + * Third-party plugins (e.g. Rank Math, Yoast) register their banners on the
551 + * global admin_notices / all_admin_notices actions, which WordPress fires on
552 + * every admin screen — so their notices leak onto our settings pages. We walk
553 + * the registered callbacks and drop any whose code lives outside this plugin's
554 + * directory, leaving every one of our own notices intact.
555 + */
556 + private function remove_third_party_admin_notices() {
557 + global $wp_filter;
558 +
559 + foreach ( array( 'admin_notices', 'all_admin_notices' ) as $hook ) {
560 + if ( empty( $wp_filter[ $hook ] ) || ! ( $wp_filter[ $hook ] instanceof WP_Hook ) ) {
561 + continue;
562 + }
563 +
564 + // Collect first, then remove — avoids mutating the callbacks array mid-walk.
565 + $to_remove = array();
566 + foreach ( $wp_filter[ $hook ]->callbacks as $priority => $callbacks ) {
567 + foreach ( $callbacks as $callback ) {
568 + if ( ! $this->is_own_admin_notice_callback( $callback['function'] ) ) {
569 + $to_remove[] = array( $callback['function'], $priority );
570 + }
571 + }
572 + }
573 +
574 + foreach ( $to_remove as $entry ) {
575 + remove_action( $hook, $entry[0], $entry[1] );
576 + }
577 + }
578 + }
579 +
580 + /**
581 + * Determine whether an admin-notice callback is defined by this plugin.
582 + *
583 + * Ownership is decided by where the callback's code physically lives, not by
584 + * class name — our notice owners use inconsistent prefixes (Metasync_Admin,
585 + * Google_Index_Admin, ConfigControllerMetaSync), and a white-label install can
586 + * rename the plugin folder. Resolving the declaring file and checking it sits
587 + * inside this plugin's directory is robust against all of that. If a callback
588 + * can't be introspected, we keep it rather than risk hiding a legitimate notice.
589 + *
590 + * @param string|array|Closure $function The registered callback.
591 + * @return bool True when the callback's code lives inside this plugin.
592 + */
593 + private function is_own_admin_notice_callback( $function ) {
594 + // Plugin root: this file lives in <plugin>/admin, so its parent is the root.
595 + $plugin_dir = wp_normalize_path( dirname( __DIR__ ) ) . '/';
596 +
597 + try {
598 + if ( is_array( $function ) && isset( $function[0], $function[1] ) ) {
599 + $class = is_object( $function[0] ) ? get_class( $function[0] ) : $function[0];
600 + $ref = new ReflectionMethod( $class, $function[1] );
601 + } elseif ( $function instanceof Closure ) {
602 + $ref = new ReflectionFunction( $function );
603 + } elseif ( is_string( $function ) && strpos( $function, '::' ) !== false ) {
604 + list( $class, $method ) = explode( '::', $function, 2 );
605 + $ref = new ReflectionMethod( $class, $method );
606 + } elseif ( is_string( $function ) ) {
607 + $ref = new ReflectionFunction( $function );
608 + } else {
609 + return false;
610 + }
611 + } catch ( \Throwable $e ) {
612 + // Unintrospectable callback — keep it; never hide a notice we can't classify.
613 + return true;
614 + }
615 +
616 + $file = $ref->getFileName();
617 + if ( ! $file ) {
618 + // Internal/built-in callback (no source file) — not ours.
619 + return false;
620 + }
621 +
622 + return strpos( wp_normalize_path( $file ), $plugin_dir ) === 0;
623 + }
624 +
483 625 #---------fixes issue : #95 ----------
484 626 #This function is to redirect in case client changes slug on fresh install
485 627 #It is called by the add_option hook
486 628
@@ -493,15 +635,22 @@
493 635
494 636 #check if we have the cookie set and check if the slug has changed
495 637 if (isset($_COOKIE['metasync_previous_slug']) && $_COOKIE['metasync_previous_slug'] !== $current_slug) {
496 638 # the slug changed so we need to update the cookie
497 - setcookie('metasync_previous_slug', $current_slug, time() + 3600, COOKIEPATH, COOKIE_DOMAIN);
639 + setcookie('metasync_previous_slug', $current_slug, [
640 + 'expires' => time() + 3600,
641 + 'path' => COOKIEPATH,
642 + 'domain' => COOKIE_DOMAIN,
643 + 'secure' => is_ssl(),
644 + 'httponly' => true,
645 + 'samesite' => 'Lax',
646 + ]);
498 647 $_COOKIE['metasync_previous_slug'] = $current_slug;
499 648
500 649 #Redirect url to the new slug
501 650 $redirect_url = admin_url('admin.php?page=' . $current_slug);
502 651
503 - wp_redirect($redirect_url);
652 + wp_safe_redirect($redirect_url);
504 653 exit;
505 654 }
506 655 }
507 656
@@ -534,9 +683,16 @@
534 683 $data = Metasync::get_option('general');
535 684 // Retrieve the current slug
536 685 $initial_slug = isset($data['white_label_plugin_menu_slug']) ? $data['white_label_plugin_menu_slug'] : self::$page_slug;
537 686 // Set the cookie
538 - setcookie('metasync_previous_slug', $initial_slug, time() + 3600, COOKIEPATH, COOKIE_DOMAIN);
687 + setcookie('metasync_previous_slug', $initial_slug, [
688 + 'expires' => time() + 3600,
689 + 'path' => COOKIEPATH,
690 + 'domain' => COOKIE_DOMAIN,
691 + 'secure' => is_ssl(),
692 + 'httponly' => true,
693 + 'samesite' => 'Lax',
694 + ]);
539 695 }
540 696 }
541 697 }
542 698
@@ -546,13 +702,11 @@
546 702 * @since 1.0.0
547 703 */
548 704 public function maybe_redirect_to_wizard() {
549 705 // Only redirect if wizard should be shown
550 - if (get_option('metasync_show_wizard') && !isset($_GET['page'])) {
551 - delete_option('metasync_show_wizard');
552 -
706 + if (get_option('metasync_show_wizard') && !isset($_GET['page'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- isset() routing check for a redirect to a fixed admin URL
553 707 // Don't redirect during AJAX, cron, or bulk activation
554 - if (wp_doing_ajax() || wp_doing_cron() || isset($_GET['activate-multi'])) {
708 + if (wp_doing_ajax() || wp_doing_cron() || isset($_GET['activate-multi'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- isset() routing check for a redirect to a fixed admin URL
555 709 return;
556 710 }
557 711
558 712 // Only redirect if user has access to the plugin
@@ -559,16 +713,66 @@
559 713 if (!Metasync::current_user_has_plugin_access()) {
560 714 return;
561 715 }
562 716
717 + delete_option('metasync_show_wizard');
563 718 wp_safe_redirect(admin_url('admin.php?page=' . self::$page_slug . '-setup-wizard'));
564 719 exit;
565 720 }
566 721 }
567 722
723 + /**
724 + * Redirect the removed standalone Instant Indexing page to Indexation Control.
725 + *
726 + * The menu entry is gone, but bookmarks and external links can still target
727 + * the old URL. Resolve the current white-label slug rather than assuming
728 + * "searchatlas".
729 + *
730 + * Hooked on admin_menu (see its registration): the unregistered-page 403 in
731 + * wp-admin/includes/menu.php fires before admin_init, so any later hook
732 + * would be too late to redirect.
733 + */
734 + public function redirect_legacy_instant_indexing_page() {
735 + if (wp_doing_ajax() || wp_doing_cron() || !is_admin() || !isset($_GET['page'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
736 + return;
737 + }
738 +
739 + $page = sanitize_key(wp_unslash($_GET['page'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to hide notices for this request
740 + $legacy_page = self::$page_slug . '-instant-index';
741 + if ($page !== $legacy_page || !Metasync::current_user_has_plugin_access()) {
742 + return;
743 + }
744 +
745 + // Indexation Control is registered only when access control keeps it
746 + // visible. Redirecting to it blindly would dead-end on an
747 + // unregistered slug (wp_die "Invalid plugin page") for users whose
748 + // white-label config hides that page — the exact configs where the
749 + // removed standalone page was the feature's only UI. The bare plugin
750 + // slug is the floor: its top-level callback resolves to the first
751 + // reachable page, so it is always a safe target.
752 + if (Metasync_Access_Control::user_can_access('hide_indexation_control')) {
753 + $target = admin_url('admin.php?page=' . self::$page_slug . '-seo-controls');
754 + $query = [];
755 + foreach (['tab', 'subtab'] as $key) {
756 + if (isset($_GET[$key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
757 + $query[$key] = sanitize_key(wp_unslash($_GET[$key])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation values for a same-site redirect
758 + }
759 + }
760 + if (!empty($query)) {
761 + $target = add_query_arg($query, $target);
762 + }
763 + } else {
764 + $target = admin_url('admin.php?page=' . self::$page_slug);
765 + }
766 +
767 + wp_safe_redirect($target);
768 + exit;
769 + }
770 +
568 771 public function metasync_display_error_log() {
569 772 Metasync_Debug_Manager::instance()->metasync_display_error_log($this);
570 773 }
774 +
571 775 public function metasync_update_wp_config() {
572 776 Metasync_Debug_Manager::instance()->metasync_update_wp_config();
573 777 }
574 778
@@ -600,13 +804,20 @@
600 804 $old_slug = $old_value['general']['white_label_plugin_menu_slug'] ?? self::$page_slug;
601 805
602 806 if ($new_slug !== $old_slug && $old_slug !=='' ){
603 807 // Set a new cookie
604 - setcookie('metasync_previous_slug', $new_slug, time() + 3600, COOKIEPATH, COOKIE_DOMAIN);
808 + setcookie('metasync_previous_slug', $new_slug, [
809 + 'expires' => time() + 3600,
810 + 'path' => COOKIEPATH,
811 + 'domain' => COOKIE_DOMAIN,
812 + 'secure' => is_ssl(),
813 + 'httponly' => true,
814 + 'samesite' => 'Lax',
815 + ]);
605 816 $_COOKIE['metasync_previous_slug'] = $new_slug;
606 817 // Redirect to the new slug
607 818 $redirect_url = admin_url('admin.php?page=' . $old_slug);
608 - wp_redirect($redirect_url);
819 + wp_safe_redirect($redirect_url);
609 820 exit;
610 821 }else{
611 822 self::$page_slug = Metasync::get_option('general')['white_label_plugin_menu_slug']=="" ? "searchatlas":Metasync::get_option('general')['white_label_plugin_menu_slug'];
612 823 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
@@ -612,9 +823,9 @@
612 823 $redirect_url = admin_url('admin.php?page=' . self::$page_slug);
613 824
614 825 #add redirection for when the old slug is not defined
615 826 #this fixes the redirect issue #
616 - wp_redirect($redirect_url);
827 + wp_safe_redirect($redirect_url);
617 828 exit;
618 829 }
619 830 }
620 831 public function metasync_view_detials_url( $plugin_meta, $plugin_file, $plugin_data ) {
@@ -625,9 +836,10 @@
625 836 if (strpos($meta, 'open-plugin-details-modal') !== false) {
626 837 $meta = sprintf(
627 838 '<a href="%s" class="thickbox open-plugin-details-modal" aria-label="%s" data-title="%s">%s</a>',
628 839 add_query_arg('TB_iframe', 'true', $plugin_uri),
629 - esc_attr(sprintf(esc_html__('More information about %s'), $plugin_data['Name'])),
840 + /* translators: %s: plugin name. */
841 + esc_attr(sprintf(esc_html__('More information about %s', 'metasync'), $plugin_data['Name'])),
630 842 esc_attr($plugin_data['Name']),
631 843 esc_html__('View details', 'metasync')
632 844 );
633 845 break; // Exit loop after replacing the link
@@ -675,53 +887,8 @@
675 887 return $all_plugins;
676 888 }
677 889
678 890 /**
679 - * Inject the whitelabel icon into the update_plugins transient so that
680 - * /wp-admin/update-core.php (Dashboard → Updates) shows the WL icon
681 - * instead of the SearchAtlas icon returned by the update API.
682 - *
683 - * @param object $transient The site transient object.
684 - * @return object
685 - */
686 - public function inject_whitelabel_icon_into_update_transient($transient) {
687 - if (empty($transient) || !is_object($transient)) {
688 - return $transient;
689 - }
690 -
691 - $general = Metasync::get_option('general');
692 - if (!is_array($general)) {
693 - return $transient;
694 - }
695 -
696 - $icon_url = $general['white_label_plugin_menu_icon'] ?? '';
697 - if (empty($icon_url)) {
698 - return $transient;
699 - }
700 -
701 - $this_plugin = plugin_basename(dirname(__DIR__) . '/metasync.php');
702 -
703 - // Inject into pending updates list
704 - if (!empty($transient->response) && isset($transient->response[$this_plugin])) {
705 - $transient->response[$this_plugin]->icons = [
706 - '1x' => $icon_url,
707 - '2x' => $icon_url,
708 - ];
709 - }
710 -
711 - // Also inject into the "no update needed" list so the icon appears
712 - // on the updates screen even when the plugin is up-to-date
713 - if (!empty($transient->no_update) && isset($transient->no_update[$this_plugin])) {
714 - $transient->no_update[$this_plugin]->icons = [
715 - '1x' => $icon_url,
716 - '2x' => $icon_url,
717 - ];
718 - }
719 -
720 - return $transient;
721 - }
722 -
723 - /**
724 891 * Register the stylesheets for the admin area.
725 892 *
726 893 * @since 1.0.0
727 894 */
@@ -726,37 +893,41 @@
726 893 * @since 1.0.0
727 894 */
728 895 public function enqueue_styles()
729 896 {
897 + $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
730 898
731 - wp_enqueue_style(
732 - $this->plugin_name,
733 - plugin_dir_url(__FILE__) . 'css/metasync-admin.css',
734 - array(),
735 - $this->version,
736 - 'all'
737 - );
899 + if (strpos($current_page, self::$page_slug) === 0) {
900 + wp_enqueue_style(
901 + $this->plugin_name,
902 + plugin_dir_url(__FILE__) . 'css/metasync-admin.css',
903 + array(),
904 + $this->version,
905 + 'all'
906 + );
738 907
739 - // Enqueue dashboard-style CSS for admin pages
740 - wp_enqueue_style(
741 - $this->plugin_name . '-dashboard',
742 - plugin_dir_url(__FILE__) . 'css/metasync-dashboard.css',
743 - array($this->plugin_name),
744 - $this->version,
745 - 'all'
746 - );
908 + // Enqueue dashboard-style CSS for admin pages
909 + wp_enqueue_style(
910 + $this->plugin_name . '-dashboard',
911 + plugin_dir_url(__FILE__) . 'css/metasync-dashboard.css',
912 + array($this->plugin_name),
913 + $this->version,
914 + 'all'
915 + );
747 916
748 - // Enqueue 3-column layout CSS
749 - wp_enqueue_style(
750 - $this->plugin_name . '-layout',
751 - plugin_dir_url(__FILE__) . 'css/metasync-layout.css',
752 - array($this->plugin_name . '-dashboard'),
753 - $this->version,
754 - 'all'
755 - );
917 + // Enqueue 3-column layout CSS
918 + wp_enqueue_style(
919 + $this->plugin_name . '-layout',
920 + plugin_dir_url(__FILE__) . 'css/metasync-layout.css',
921 + array($this->plugin_name . '-dashboard'),
922 + $this->version,
923 + 'all'
924 + );
925 + }
756 926
757 - // Enqueue wizard CSS if on wizard page
758 - if (isset($_GET['page']) && strpos($_GET['page'], '-setup-wizard') !== false) {
927 + // Enqueue wizard CSS on all MetaSync pages because the reusable consent
928 + // card is also displayed inside General Settings.
929 + if (strpos($current_page, self::$page_slug) === 0 || (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-setup-wizard') !== false)) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
759 930 wp_enqueue_style(
760 931 $this->plugin_name . '-setup-wizard',
761 932 plugin_dir_url(__FILE__) . 'css/metasync-setup-wizard.css',
762 933 array($this->plugin_name . '-dashboard'),
@@ -765,9 +936,9 @@
765 936 );
766 937 }
767 938
768 939 // Enqueue SEO Health CSS if on the SEO Health page
769 - if (isset($_GET['page']) && strpos($_GET['page'], '-seo-health') !== false) {
940 + if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-seo-health') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick stylesheets
770 941 wp_enqueue_style(
771 942 $this->plugin_name . '-seo-health',
772 943 plugin_dir_url(__FILE__) . 'css/metasync-seo-health.css',
773 944 array($this->plugin_name . '-dashboard'),
@@ -783,41 +954,82 @@
783 954 * @since 1.0.0
784 955 */
785 956 public function enqueue_scripts()
786 957 {
958 + // --- Phase 5 (#887): Extracted inline JS files ---
959 + $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick scripts
960 + $plugin_root_url = plugin_dir_url(dirname(__FILE__));
961 + $is_metasync_page = ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0);
787 962
788 - wp_enqueue_media();
963 + if ($is_metasync_page) {
964 + wp_enqueue_media();
789 965
790 - wp_enqueue_script(
791 - $this->plugin_name,
792 - plugin_dir_url(__FILE__) . 'js/metasync-admin.js',
793 - array('jquery'),
794 - $this->version,
795 - false
796 - );
966 + wp_enqueue_script(
967 + $this->plugin_name,
968 + plugin_dir_url(__FILE__) . 'js/metasync-admin.js',
969 + array('jquery'),
970 + $this->version,
971 + false
972 + );
797 973
798 - // Enqueue dashboard-style JavaScript for enhanced interactions
799 - wp_enqueue_script(
800 - $this->plugin_name . '-dashboard',
801 - plugin_dir_url(__FILE__) . 'js/metasync-dashboard.js',
802 - array('jquery', $this->plugin_name),
803 - $this->version,
804 - true
805 - );
974 + // Enqueue dashboard-style JavaScript for enhanced interactions
975 + wp_enqueue_script(
976 + $this->plugin_name . '-dashboard',
977 + plugin_dir_url(__FILE__) . 'js/metasync-dashboard.js',
978 + array('jquery', $this->plugin_name),
979 + $this->version,
980 + true
981 + );
806 982
807 - # Enqueue theme switcher
808 - wp_enqueue_script(
809 - $this->plugin_name . '-theme-switcher',
810 - plugin_dir_url(__FILE__) . 'js/metasync-theme-switcher.js',
811 - array('jquery'),
812 - $this->version,
813 - true
814 - );
815 -
816 - // --- Phase 5 (#887): Extracted inline JS files ---
817 - $current_page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
818 - $plugin_root_url = plugin_dir_url(dirname(__FILE__));
983 + # Enqueue theme switcher
984 + wp_enqueue_script(
985 + $this->plugin_name . '-theme-switcher',
986 + plugin_dir_url(__FILE__) . 'js/metasync-theme-switcher.js',
987 + array('jquery'),
988 + $this->version,
989 + true
990 + );
819 991
992 + // Keeps core's admin-menu pinning in step with pages whose height
993 + // changes after load. Depends on 'common' so core has bound its
994 + // own handlers before this runs.
995 + wp_enqueue_script(
996 + $this->plugin_name . '-admin-menu-height',
997 + plugin_dir_url(__FILE__) . 'js/metasync-admin-menu-height.js',
998 + array('jquery', 'common'),
999 + $this->version,
1000 + true
1001 + );
1002 + }
1003 +
1004 + $per_page_screens = [
1005 + self::$page_slug . '-redirections',
1006 + self::$page_slug . '-404-monitor',
1007 + self::$page_slug . '-sync-log',
1008 + self::$page_slug . '-media-optimization',
1009 + ];
1010 +
1011 + if (in_array($current_page, $per_page_screens, true)) {
1012 + // Results-per-page selectors use this external handler instead of
1013 + // an inline event attribute so changing a page size always reloads
1014 + // the current admin page.
1015 + wp_enqueue_script(
1016 + $this->plugin_name . '-per-page',
1017 + plugin_dir_url(__FILE__) . 'js/metasync-per-page.js',
1018 + array(),
1019 + $this->version,
1020 + true
1021 + );
1022 + // Selector navigation is a GET, so the per-page nonce rides along
1023 + // as a query parameter and gates the user_meta persist in
1024 + // Metasync_Per_Page_Helper::resolve().
1025 + wp_localize_script(
1026 + $this->plugin_name . '-per-page',
1027 + 'metasyncPerPage',
1028 + array('nonces' => Metasync_Per_Page_Helper::page_nonces())
1029 + );
1030 + }
1031 +
820 1032 // Dashboard iframe height (only on dashboard page)
821 1033 if ($current_page === self::$page_slug . '-dashboard' || $current_page === self::$page_slug) {
822 1034 wp_enqueue_script(
823 1035 $this->plugin_name . '-iframe',
@@ -828,9 +1040,9 @@
828 1040 );
829 1041 }
830 1042
831 1043 // Settings page scripts (save btn, clear settings, bing key, access roles)
832 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1044 + if ($current_page === self::$page_slug) {
833 1045 wp_enqueue_script(
834 1046 $this->plugin_name . '-settings',
835 1047 plugin_dir_url(__FILE__) . 'js/metasync-settings.js',
836 1048 array('jquery'),
@@ -866,12 +1078,12 @@
866 1078 true
867 1079 );
868 1080 // Pass the active tab from server (handles POST redirect)
869 1081 $sitemap_active_tab = 'general';
870 - if (isset($_GET['tab']) && in_array($_GET['tab'], ['general', 'news', 'video'], true)) {
871 - $sitemap_active_tab = sanitize_text_field(wp_unslash($_GET['tab']));
872 - } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) {
873 - $sitemap_active_tab = sanitize_text_field(wp_unslash($_POST['redirect_tab']));
1082 + if (isset($_GET['tab']) && in_array($_GET['tab'], ['general', 'news', 'video'], true)) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed tab selection for the UI
1083 + $sitemap_active_tab = sanitize_text_field(wp_unslash($_GET['tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed tab selection for the UI
1084 + } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only, allow-listed tab selection for the UI
1085 + $sitemap_active_tab = sanitize_text_field(wp_unslash($_POST['redirect_tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only, allow-listed tab selection for the UI
874 1086 }
875 1087 wp_localize_script($this->plugin_name . '-sitemap-tabs', 'metasyncSitemapTabs', [
876 1088 'activeTab' => $sitemap_active_tab,
877 1089 ]);
@@ -877,9 +1089,9 @@
877 1089 ]);
878 1090 }
879 1091
880 1092 // Error logs — copy to clipboard
881 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1093 + if ($current_page === self::$page_slug) {
882 1094 wp_enqueue_script(
883 1095 $this->plugin_name . '-error-logs',
884 1096 $plugin_root_url . 'site-error-logs/js/metasync-error-logs.js',
885 1097 array(),
@@ -888,9 +1100,9 @@
888 1100 );
889 1101 }
890 1102
891 1103 // Access control UI
892 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1104 + if ($current_page === self::$page_slug) {
893 1105 wp_enqueue_script(
894 1106 $this->plugin_name . '-access-control',
895 1107 $plugin_root_url . 'includes/js/metasync-access-control.js',
896 1108 array(),
@@ -927,9 +1139,9 @@
927 1139 // --- Phase 5 Part B: Extracted inline JS with wp_localize_script ---
928 1140
929 1141 // Navigation portal menus (top bar + settings inner page)
930 1142 $whitelabel_data = Metasync::get_option('whitelabel');
931 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1143 + if ($is_metasync_page) {
932 1144 wp_enqueue_script(
933 1145 $this->plugin_name . '-navigation',
934 1146 plugin_dir_url(__FILE__) . 'js/metasync-navigation.js',
935 1147 array(),
@@ -943,9 +1155,9 @@
943 1155 ));
944 1156 }
945 1157
946 1158 // Debug mode timer
947 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1159 + if ($current_page === self::$page_slug) {
948 1160 $debug_manager = Metasync_Debug_Mode_Manager::get_instance();
949 1161 $debug_status = $debug_manager->get_status();
950 1162 wp_enqueue_script(
951 1163 $this->plugin_name . '-debug-mode',
@@ -963,9 +1175,9 @@
963 1175 ));
964 1176 }
965 1177
966 1178 // MetasyncConfig + admin bar sync
967 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1179 + if ($is_metasync_page) {
968 1180 wp_enqueue_script(
969 1181 $this->plugin_name . '-config',
970 1182 plugin_dir_url(__FILE__) . 'js/metasync-config.js',
971 1183 array('jquery'),
@@ -978,9 +1190,9 @@
978 1190 ));
979 1191 }
980 1192
981 1193 // Whitelabel password (forgot password handler)
982 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1194 + if ($current_page === self::$page_slug) {
983 1195 wp_enqueue_script(
984 1196 $this->plugin_name . '-whitelabel',
985 1197 plugin_dir_url(__FILE__) . 'js/metasync-whitelabel.js',
986 1198 array('jquery'),
@@ -992,9 +1204,9 @@
992 1204 ));
993 1205 }
994 1206
995 1207 // Whitelabel connect (validation modal + lock section + export)
996 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1208 + if ($current_page === self::$page_slug) {
997 1209 wp_enqueue_script(
998 1210 $this->plugin_name . '-connect',
999 1211 plugin_dir_url(__FILE__) . 'js/metasync-connect.js',
1000 1212 array('jquery'),
@@ -1002,9 +1214,8 @@
1002 1214 true
1003 1215 );
1004 1216 wp_localize_script($this->plugin_name . '-connect', 'metasyncConnectData', array(
1005 1217 'optionKey' => self::option_key,
1006 - 'storedPassword' => isset($whitelabel_data['settings_password']) ? $whitelabel_data['settings_password'] : '',
1007 1218 'adminPostUrl' => admin_url('admin-post.php'),
1008 1219 'exportNonce' => wp_create_nonce('metasync_export_whitelabel'),
1009 1220 'ajaxUrl' => admin_url('admin-ajax.php'),
1010 1221 'logoutNonceField' => wp_nonce_field('whitelabel_logout_nonce', 'whitelabel_logout_nonce', true, false),
@@ -1011,9 +1222,9 @@
1011 1222 ));
1012 1223 }
1013 1224
1014 1225 // Host blocking test (settings + dashboard)
1015 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1226 + if ($current_page === self::$page_slug || $current_page === self::$page_slug . '-dashboard') {
1016 1227 wp_enqueue_script(
1017 1228 $this->plugin_name . '-host-blocking',
1018 1229 plugin_dir_url(__FILE__) . 'js/metasync-host-blocking.js',
1019 1230 array('jquery'),
@@ -1021,13 +1232,14 @@
1021 1232 true
1022 1233 );
1023 1234 wp_localize_script($this->plugin_name . '-host-blocking', 'metasyncHostBlockingData', array(
1024 1235 'ajaxUrl' => admin_url('admin-ajax.php'),
1236 + 'nonce' => wp_create_nonce('metasync_nonce'),
1025 1237 ));
1026 1238 }
1027 1239
1028 - // OTTO excluded URLs
1029 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1240 + // OTTO excluded URLs (lives on the Compatibility page)
1241 + if ($current_page === self::$page_slug . '-compatibility') {
1030 1242 wp_enqueue_script(
1031 1243 $this->plugin_name . '-excluded-urls',
1032 1244 plugin_dir_url(__FILE__) . 'js/metasync-excluded-urls.js',
1033 1245 array('jquery'),
@@ -1040,9 +1252,9 @@
1040 1252 ));
1041 1253 }
1042 1254
1043 1255 // Execution settings form
1044 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1256 + if ($current_page === self::$page_slug) {
1045 1257 $server_limits = $this->get_server_limits();
1046 1258 wp_enqueue_script(
1047 1259 $this->plugin_name . '-execution-settings',
1048 1260 plugin_dir_url(__FILE__) . 'js/metasync-execution-settings.js',
@@ -1057,18 +1269,21 @@
1057 1269 ));
1058 1270 }
1059 1271
1060 1272 // Quick edit badge (custom pages)
1061 - wp_enqueue_script(
1062 - $this->plugin_name . '-quick-edit',
1063 - plugin_dir_url(__FILE__) . 'js/metasync-quick-edit.js',
1064 - array('jquery'),
1065 - $this->version,
1066 - true
1067 - );
1068 - wp_localize_script($this->plugin_name . '-quick-edit', 'metasyncQuickEditData', array(
1069 - 'standardPageLabel' => __('Standard page', 'metasync'),
1070 - ));
1273 + global $pagenow;
1274 + if ($pagenow === 'edit.php') {
1275 + wp_enqueue_script(
1276 + $this->plugin_name . '-quick-edit',
1277 + plugin_dir_url(__FILE__) . 'js/metasync-quick-edit.js',
1278 + array('jquery'),
1279 + $this->version,
1280 + true
1281 + );
1282 + wp_localize_script($this->plugin_name . '-quick-edit', 'metasyncQuickEditData', array(
1283 + 'standardPageLabel' => __('Standard page', 'metasync'),
1284 + ));
1285 + }
1071 1286
1072 1287 // Report issue form
1073 1288 if ($current_page === self::$page_slug . '-report-issue') {
1074 1289 wp_enqueue_script(
@@ -1088,8 +1303,11 @@
1088 1303 array('jquery'),
1089 1304 $this->version,
1090 1305 true
1091 1306 );
1307 + wp_localize_script($this->plugin_name . '-bing-console', 'metasyncBingConsoleData', array(
1308 + 'nonce' => wp_create_nonce('metasync_nonce'),
1309 + ));
1092 1310 }
1093 1311
1094 1312 // Add redirection form
1095 1313 if ($current_page === self::$page_slug . '-redirections') {
@@ -1117,9 +1335,9 @@
1117 1335 ));
1118 1336 }
1119 1337
1120 1338 // Import external data (SEO metadata)
1121 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1339 + if ($current_page === self::$page_slug . '-import-external') {
1122 1340 wp_enqueue_script(
1123 1341 $this->plugin_name . '-import-external-data',
1124 1342 $plugin_root_url . 'views/js/metasync-import-external-data.js',
1125 1343 array('jquery'),
@@ -1132,9 +1350,9 @@
1132 1350 ));
1133 1351 }
1134 1352
1135 1353 // OTTO bot statistics
1136 - if ($current_page === self::$page_slug || strpos($current_page, self::$page_slug) === 0) {
1354 + if ($current_page === self::$page_slug . '-bot-statistics') {
1137 1355 wp_enqueue_script(
1138 1356 $this->plugin_name . '-bot-statistics',
1139 1357 $plugin_root_url . 'views/js/metasync-bot-statistics.js',
1140 1358 array('jquery'),
@@ -1161,105 +1379,108 @@
1161 1379 }
1162 1380
1163 1381 // --- End Phase 5 enqueues ---
1164 1382
1165 - # Localize theme switcher script
1166 - wp_localize_script(
1167 - $this->plugin_name . '-theme-switcher',
1168 - 'metasyncThemeData',
1169 - array(
1170 - 'ajaxUrl' => admin_url('admin-ajax.php'),
1171 - 'nonce' => wp_create_nonce('metasync_theme_nonce'),
1172 - 'currentTheme' => get_option('metasync_theme', 'dark')
1173 - )
1174 - );
1383 + if ($is_metasync_page) {
1384 + # Localize theme switcher script
1385 + wp_localize_script(
1386 + $this->plugin_name . '-theme-switcher',
1387 + 'metasyncThemeData',
1388 + array(
1389 + 'ajaxUrl' => admin_url('admin-ajax.php'),
1390 + 'nonce' => wp_create_nonce('metasync_theme_nonce'),
1391 + 'currentTheme' => get_option('metasync_theme', 'dark')
1392 + )
1393 + );
1175 1394
1176 - // Localize the script to make the AJAX URL accessible
1177 - $options = Metasync::get_option('general');
1178 - // Get connection status for JavaScript
1179 - $general_settings = Metasync::get_option('general');
1180 - $searchatlas_api_key = isset($general_settings['searchatlas_api_key']) ? $general_settings['searchatlas_api_key'] : '';
1181 - $otto_pixel_uuid = isset($general_settings['otto_pixel_uuid']) ? $general_settings['otto_pixel_uuid'] : '';
1182 -
1183 - // SECURITY FIX (CVE-2025-14386): Only generate Search Atlas connect nonce for administrators
1184 - // Using strict capability check instead of plugin access roles
1185 - $sa_connect_nonce = '';
1186 - if (current_user_can('manage_options')) {
1187 - $sa_connect_nonce = wp_create_nonce('metasync_sa_connect_nonce');
1395 + // Get connection status for JavaScript
1396 + $general_settings = Metasync::get_option('general');
1397 + $searchatlas_api_key = isset($general_settings['searchatlas_api_key']) ? $general_settings['searchatlas_api_key'] : '';
1398 + $otto_pixel_uuid = isset($general_settings['otto_pixel_uuid']) ? $general_settings['otto_pixel_uuid'] : '';
1399 +
1400 + // SECURITY FIX (CVE-2025-14386): Only generate Search Atlas connect nonce for administrators
1401 + // Using strict capability check instead of plugin access roles
1402 + $sa_connect_nonce = '';
1403 + if (current_user_can('manage_options')) {
1404 + $sa_connect_nonce = wp_create_nonce('metasync_sa_connect_nonce');
1405 + }
1406 +
1407 + $heartbeat_state = $this->get_heartbeat_state();
1408 + wp_localize_script( $this->plugin_name, 'metaSync', array(
1409 + 'ajax_url' => admin_url( 'admin-ajax.php' ),
1410 + 'admin_url'=>admin_url('admin.php'),
1411 + 'nonce' => wp_create_nonce('metasync_nonce'),
1412 + 'sa_connect_nonce' => $sa_connect_nonce,
1413 + 'reset_auth_nonce' => wp_create_nonce('metasync_reset_auth_nonce'),
1414 + 'burst_ping_nonce' => wp_create_nonce('metasync_burst_ping'),
1415 + 'clear_otto_cache_nonce' => wp_create_nonce('metasync_clear_otto_cache'),
1416 + 'lglogin_nonce' => wp_create_nonce('metasync_lglogin'),
1417 + 'heartbeat_state' => $heartbeat_state,
1418 + 'dashboard_domain' => self::get_effective_dashboard_domain(),
1419 + 'support_email' => Metasync::SUPPORT_EMAIL,
1420 + 'documentation_domain' => Metasync::DOCUMENTATION_DOMAIN,
1421 + 'debug_enabled' => WP_DEBUG || (defined('METASYNC_DEBUG') && constant('METASYNC_DEBUG')),
1422 + 'searchatlas_api_key' => !empty($searchatlas_api_key),
1423 + 'otto_pixel_uuid' => $otto_pixel_uuid,
1424 + 'is_connected' => (bool)$this->is_heartbeat_connected()
1425 + ));
1426 +
1427 + // Ensure ajaxurl is available for admin pages (WordPress standard)
1428 + // This creates a global ajaxurl variable for JavaScript
1429 + wp_enqueue_script('wp-util');
1430 +
1431 + // Add inline script to ensure ajaxurl is defined
1432 + $inline_script = "
1433 + if (typeof ajaxurl === 'undefined') {
1434 + var ajaxurl = '" . esc_js(admin_url('admin-ajax.php')) . "';
1435 + }
1436 +
1437 + // Add Plugin Auth Token refresh functionality
1438 + jQuery(document).ready(function($) {
1439 + $('#refresh-plugin-auth-token').click(function() {
1440 + var button = $(this);
1441 + var originalText = button.text();
1442 +
1443 + if (confirm('Are you sure you want to refresh the Plugin Auth Token? This will generate a new token and update the heartbeat API.')) {
1444 + // Disable button and show loading
1445 + button.prop('disabled', true).text('🔄 Refreshing...');
1446 +
1447 + $.post(ajaxurl, {
1448 + action: 'metasync_refresh_plugin_auth_token',
1449 + nonce: '" . wp_create_nonce('metasync_refresh_plugin_auth_token') . "'
1450 + })
1451 + .done(function(response) {
1452 + if (response.success && response.data && response.data.new_token) {
1453 + // Update the field value immediately
1454 + $('#apikey').val(response.data.new_token);
1455 +
1456 + // Visual feedback with green border
1457 + $('#apikey').css('border', '2px solid #28a745').animate({borderColor: '#ddd'}, 2000);
1458 +
1459 + alert('✅ Plugin Auth Token refreshed successfully!\\n\\nNew token: ' + response.data.new_token.substring(0, 8) + '...');
1460 + } else {
1461 + alert('❌ Error refreshing token: ' + (response.data ? response.data.message : 'Unknown error'));
1462 + }
1463 + })
1464 + .fail(function() {
1465 + alert('❌ Network error while refreshing token');
1466 + })
1467 + .always(function() {
1468 + // Re-enable button
1469 + button.prop('disabled', false).text(originalText);
1470 + });
1471 + }
1472 + });
1473 + });
1474 + ";
1475 + wp_add_inline_script($this->plugin_name, $inline_script);
1188 1476 }
1189 -
1190 - $heartbeat_state = $this->get_heartbeat_state();
1191 - wp_localize_script( $this->plugin_name, 'metaSync', array(
1192 - 'ajax_url' => admin_url( 'admin-ajax.php' ),
1193 - 'admin_url'=>admin_url('admin.php'),
1194 - 'sa_connect_nonce' => $sa_connect_nonce,
1195 - 'reset_auth_nonce' => wp_create_nonce('metasync_reset_auth_nonce'),
1196 - 'burst_ping_nonce' => wp_create_nonce('metasync_burst_ping'),
1197 - 'heartbeat_state' => $heartbeat_state,
1198 - 'dashboard_domain' => self::get_effective_dashboard_domain(),
1199 - 'support_email' => Metasync::SUPPORT_EMAIL,
1200 - 'documentation_domain' => Metasync::DOCUMENTATION_DOMAIN,
1201 - 'debug_enabled' => WP_DEBUG || (defined('METASYNC_DEBUG') && constant('METASYNC_DEBUG')),
1202 - 'searchatlas_api_key' => !empty($searchatlas_api_key),
1203 - 'otto_pixel_uuid' => $otto_pixel_uuid,
1204 - 'is_connected' => (bool)$this->is_heartbeat_connected()
1205 - ));
1206 -
1207 - // Ensure ajaxurl is available for admin pages (WordPress standard)
1208 - // This creates a global ajaxurl variable for JavaScript
1209 - wp_enqueue_script('wp-util');
1210 -
1211 - // Add inline script to ensure ajaxurl is defined
1212 - $inline_script = "
1213 - if (typeof ajaxurl === 'undefined') {
1214 - var ajaxurl = '" . esc_js(admin_url('admin-ajax.php')) . "';
1215 - }
1216 -
1217 - // Add Plugin Auth Token refresh functionality
1218 - jQuery(document).ready(function($) {
1219 - $('#refresh-plugin-auth-token').click(function() {
1220 - var button = $(this);
1221 - var originalText = button.text();
1222 -
1223 - if (confirm('Are you sure you want to refresh the Plugin Auth Token? This will generate a new token and update the heartbeat API.')) {
1224 - // Disable button and show loading
1225 - button.prop('disabled', true).text('🔄 Refreshing...');
1226 -
1227 - $.post(ajaxurl, {
1228 - action: 'metasync_refresh_plugin_auth_token',
1229 - nonce: '" . wp_create_nonce('metasync_refresh_plugin_auth_token') . "'
1230 - })
1231 - .done(function(response) {
1232 - if (response.success && response.data && response.data.new_token) {
1233 - // Update the field value immediately
1234 - $('#apikey').val(response.data.new_token);
1235 -
1236 - // Visual feedback with green border
1237 - $('#apikey').css('border', '2px solid #28a745').animate({borderColor: '#ddd'}, 2000);
1238 -
1239 - alert('✅ Plugin Auth Token refreshed successfully!\\n\\nNew token: ' + response.data.new_token.substring(0, 8) + '...');
1240 - } else {
1241 - alert('❌ Error refreshing token: ' + (response.data ? response.data.message : 'Unknown error'));
1242 - }
1243 - })
1244 - .fail(function() {
1245 - alert('❌ Network error while refreshing token');
1246 - })
1247 - .always(function() {
1248 - // Re-enable button
1249 - button.prop('disabled', false).text(originalText);
1250 - });
1251 - }
1252 - });
1253 - });
1254 - ";
1255 - wp_add_inline_script($this->plugin_name, $inline_script);
1256 1477 add_action('admin_notices', array($this, 'permalink_structure_dashboard_warning'));
1257 1478 add_action('admin_notices', array($this, 'display_page_builder_notice'));
1258 1479 // Display update warning banner if plugin update is available
1259 1480 add_action('admin_notices', array($this, 'display_update_warning_banner'));
1260 1481 // Enqueue wizard assets if on wizard page
1261 - if (isset($_GET['page']) && strpos($_GET['page'], '-setup-wizard') !== false) {
1482 + if (isset($_GET['page']) && strpos(sanitize_key(wp_unslash($_GET['page'])), '-setup-wizard') !== false) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check to pick scripts
1262 1483 wp_enqueue_script(
1263 1484 $this->plugin_name . '-setup-wizard',
1264 1485 plugin_dir_url(__FILE__) . 'js/metasync-setup-wizard.js',
1265 1486 array('jquery'),
@@ -1283,14 +1504,44 @@
1283 1504 }
1284 1505
1285 1506 /**
1286 1507 * Settings of HeartBeat API for admin area.
1287 - * Set time interval of send request.
1508 + *
1509 + * The only thing the plugin rides the beat for is a periodic admin
1510 + * telemetry ping (the `heartbeat-send` handler in admin/js/metasync-admin.js),
1511 + * so it slows the beat down rather than speeding it up. Two contexts are
1512 + * left on whatever core decided, because core leans on the beat there:
1513 + *
1514 + * - The front end, where the beat drives wp-auth-check — the "your session
1515 + * has expired, log in again" prompt.
1516 + * - The editor screens, where each tick renews the post lock. A lock only
1517 + * lives for wp_check_post_lock_window() seconds (150 by default), so any
1518 + * interval longer than that drops the lock between beats and lets a
1519 + * second editor silently take over a post someone still has open.
1520 + *
1521 + * Core clamps this to 1-3600 seconds in wp-includes/js/heartbeat.js, so an
1522 + * over-long interval is not corrected on our behalf.
1523 + *
1524 + * @param array $settings Heartbeat settings array.
1525 + * @return array Heartbeat settings array.
1288 1526 */
1289 1527 function metasync_heartbeat_settings($settings)
1290 1528 {
1291 - global $heartbeat_frequency;
1292 - $settings['interval'] = 300;
1529 + if (!is_admin()) {
1530 + return $settings;
1531 + }
1532 +
1533 + # $pagenow is set in wp-includes/vars.php, long before this filter runs;
1534 + # core's own wp_heartbeat_set_suspension() reads it exactly this way.
1535 + global $pagenow;
1536 + $lock_dependent_screens = array('post.php', 'post-new.php', 'customize.php');
1537 + if (isset($pagenow) && in_array($pagenow, $lock_dependent_screens, true)) {
1538 + return $settings;
1539 + }
1540 +
1541 + # 60s matches heartbeat.js's own default and stays well inside the
1542 + # 150s post-lock window, so nothing core does can fall through a gap.
1543 + $settings['interval'] = 60;
1293 1544 return $settings;
1294 1545 }
1295 1546
1296 1547 /**
@@ -1428,13 +1679,8 @@
1428 1679 {
1429 1680 Metasync_Connect_Manager::instance()->test_searchatlas_ajax_endpoint();
1430 1681 }
1431 1682
1432 - public function simple_ajax_test()
1433 - {
1434 - Metasync_Connect_Manager::instance()->simple_ajax_test();
1435 - }
1436 -
1437 1683 /**
1438 1684 * Create Admin Dashboard Iframe Page
1439 1685 * Embeds the Search Atlas dashboard directly in WordPress admin
1440 1686 */
@@ -1459,8 +1705,47 @@
1459 1705 // Get cache count
1460 1706 $cache_count = Metasync_Otto_Transient_Cache::get_cache_count();
1461 1707
1462 1708 ?>
1709 + <!-- OTTO Cache TTL Setting -->
1710 + <div style="margin-bottom: 30px; padding-top: 20px;">
1711 + <h4 style="margin-top: 0; color: var(--dashboard-text-primary);"><?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> Cache TTL</h4>
1712 + <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">
1713 + Configure how long <?php echo esc_html(Metasync::get_whitelabel_otto_name()); ?> API suggestions are cached before a fresh API call. The stale fallback expires after 12 hours.
1714 + </p>
1715 +
1716 + <div style="background: rgba(255,255,255,0.02); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
1717 + <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 12px;">
1718 + <label for="metasync-otto-cache-ttl" style="color: var(--dashboard-text-primary); font-weight: 500;">
1719 + Cache TTL (minutes):
1720 + </label>
1721 + <?php Metasync::render_tooltip_icon('otto_cache_ttl', 'How many minutes OTTO reuses its last SEO suggestions before fetching fresh ones. 30 is fine for most sites.'); ?>
1722 + <input type="number"
1723 + id="metasync-otto-cache-ttl"
1724 + value="<?php echo esc_attr($this->get_otto_cache_ttl_minutes()); ?>"
1725 + min="30"
1726 + max="1440"
1727 + step="1"
1728 + style="width: 100px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg, rgba(255,255,255,0.05)); color: var(--dashboard-text-primary);" />
1729 + <span style="color: var(--dashboard-text-secondary); font-size: 13px;">min 30 · max 1440</span>
1730 + </div>
1731 +
1732 + <div style="display: flex; align-items: center; gap: 12px;">
1733 + <button type="button"
1734 + id="metasync-otto-ttl-save-btn"
1735 + class="metasync-btn-primary"
1736 + style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 9px 18px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease;"
1737 + onmouseover="this.style.transform='translateY(-1px)';"
1738 + onmouseout="this.style.transform='translateY(0)';">
1739 + Save TTL
1740 + </button>
1741 + <span id="metasync-otto-ttl-save-msg" style="display: none; font-size: 13px;"></span>
1742 + </div>
1743 +
1744 + <input type="hidden" id="metasync-otto-ttl-nonce" value="<?php echo esc_attr(wp_create_nonce('metasync_otto_cache_ttl_nonce')); ?>" />
1745 + </div>
1746 + </div>
1747 +
1463 1748 <!-- Cache Plugin Management -->
1464 1749 <div style="margin-bottom: 30px;">
1465 1750 <h4 style="margin-top: 0; color: var(--dashboard-text-primary);">Clear All Cache Plugins</h4>
1466 1751 <p style="margin-bottom: 15px; color: var(--dashboard-text-secondary);">Clear all cache plugins to ensure changes are visible immediately.</p>
@@ -1482,9 +1767,10 @@
1482 1767 } else {
1483 1768 echo '<p style="color: var(--dashboard-text-secondary);">ℹ️ No cache plugins detected.</p>';
1484 1769 }
1485 1770 } catch (Exception $e) {
1486 - echo '<p style="color: var(--dashboard-error);">⚠️ Error: ' . esc_html($e->getMessage()) . '</p>';
1771 + error_log('MetaSync Cache Status Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1772 + echo '<p style="color: var(--dashboard-error);">⚠️ An error occurred while retrieving cache plugin status.</p>';
1487 1773 }
1488 1774 } else {
1489 1775 echo '<p style="color: var(--dashboard-error);">⚠️ Cache Purge class not loaded.</p>';
1490 1776 }
@@ -1493,9 +1779,9 @@
1493 1779 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="margin-top: 15px;">
1494 1780 <input type="hidden" name="action" value="metasync_clear_all_cache_plugins" />
1495 1781 <?php wp_nonce_field('metasync_clear_cache_nonce', 'clear_cache_nonce'); ?>
1496 1782 <button type="submit" class="metasync-btn-primary" style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 10px 20px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); display: inline-block; width: auto; min-width: 240px; max-width: fit-content;" onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 8px rgba(0, 0, 0, 0.15)';" onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 2px 4px rgba(0, 0, 0, 0.1)';">
1497 - <span class="dashicons dashicons-controls-repeat" style="margin-top:3px;font-size:15px;width:15px;height:15px;"></span> Clear All Cache Plugins
1783 + <span class="dashicons dashicons-controls-repeat"></span> Clear All Cache Plugins
1498 1784 </button>
1499 1785 <p class="description" style="margin-top: 10px; color: var(--dashboard-text-secondary);">This will clear cache from WP Rocket, LiteSpeed, W3 Total Cache, and all other detected cache plugins.</p>
1500 1786 </form>
1501 1787
@@ -1500,16 +1786,16 @@
1500 1786 </form>
1501 1787
1502 1788 <?php
1503 1789 // Display success/error messages
1504 - if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') {
1505 - $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0;
1506 - $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0;
1507 - $plugins = isset($_GET['plugins']) ? sanitize_text_field(wp_unslash($_GET['plugins'])) : '';
1790 + if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1791 + $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1792 + $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1793 + $plugins = isset($_GET['plugins']) ? sanitize_text_field(wp_unslash($_GET['plugins'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1508 1794
1509 1795 if ($cleared > 0) {
1510 1796 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1511 - echo '✅ <strong>Success!</strong> Cleared cache for ' . $cleared . ' plugin(s)';
1797 + echo '✅ <strong>Success!</strong> Cleared cache for ' . intval( $cleared ) . ' plugin(s)';
1512 1798 if ($plugins) {
1513 1799 echo ': ' . esc_html($plugins);
1514 1800 }
1515 1801 echo '</p></div>';
@@ -1520,15 +1806,15 @@
1520 1806 }
1521 1807
1522 1808 if ($failed > 0) {
1523 1809 echo '<div class="notice notice-warning inline" style="margin-top: 15px;"><p>';
1524 - echo '⚠️ Failed to clear ' . $failed . ' plugin(s).';
1810 + echo '⚠️ Failed to clear ' . intval( $failed ) . ' plugin(s).';
1525 1811 echo '</p></div>';
1526 1812 }
1527 1813 }
1528 1814
1529 - if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') {
1530 - $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : '';
1815 + if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1816 + $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1531 1817 if (empty($message)) {
1532 1818 $message = 'An unknown error occurred while clearing cache. Please check error logs for details.';
1533 1819 }
1534 1820 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
@@ -1577,8 +1863,9 @@
1577 1863 <?php checked(true, !empty($hosting_settings['wpengine_enabled'])); ?>
1578 1864 <?php echo !$wpe_detected ? 'disabled' : ''; ?>
1579 1865 style="width: 16px; height: 16px; cursor: <?php echo $wpe_detected ? 'pointer' : 'not-allowed'; ?>;" />
1580 1866 <span style="color: var(--dashboard-text-primary); font-weight: 500;">WP Engine</span>
1867 + <?php Metasync::render_tooltip_icon('hosting_cache_wpengine', 'If hosted on WP Engine, turn on so OTTO clears the host\'s built-in cache when it updates a page.'); ?>
1581 1868 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges Varnish + Memcached</span>
1582 1869 </label>
1583 1870
1584 1871 <label style="display: flex; align-items: center; gap: 10px; margin-bottom: 16px; cursor: pointer;">
@@ -1587,8 +1874,9 @@
1587 1874 <?php checked(true, !empty($hosting_settings['kinsta_enabled'])); ?>
1588 1875 <?php echo !$kinsta_detected ? 'disabled' : ''; ?>
1589 1876 style="width: 16px; height: 16px; cursor: <?php echo $kinsta_detected ? 'pointer' : 'not-allowed'; ?>;" />
1590 1877 <span style="color: var(--dashboard-text-primary); font-weight: 500;">Kinsta</span>
1878 + <?php Metasync::render_tooltip_icon('hosting_cache_kinsta', 'If hosted on Kinsta, turn on so OTTO clears Kinsta\'s server cache automatically.'); ?>
1591 1879 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">— purges full-page cache (kinsta_cache_purge_full)</span>
1592 1880 </label>
1593 1881
1594 1882 <div style="display: flex; align-items: center; gap: 12px;">
@@ -1602,9 +1890,9 @@
1602 1890 </button>
1603 1891 <span id="metasync-hc-save-msg" style="display: none; font-size: 13px;"></span>
1604 1892 </div>
1605 1893
1606 - <input type="hidden" id="metasync-hc-nonce" value="<?php echo wp_create_nonce('metasync_hosting_cache_nonce'); ?>" />
1894 + <input type="hidden" id="metasync-hc-nonce" value="<?php echo esc_attr(wp_create_nonce('metasync_hosting_cache_nonce')); ?>" />
1607 1895 </div>
1608 1896
1609 1897 <!-- Purge button -->
1610 1898 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
@@ -1623,12 +1911,13 @@
1623 1911 </form>
1624 1912
1625 1913 <?php
1626 1914 // Hosting cache result messages
1627 - if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') {
1628 - $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode($_GET['hc_cleared'])) : '';
1629 - $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode($_GET['hc_failed'])) : '';
1630 - $hc_not_detected = isset($_GET['hc_not_detected']) ? sanitize_text_field(urldecode($_GET['hc_not_detected'])) : '';
1915 + if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1916 + // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each value is unslashed and sanitized on the following assignments.
1917 + $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_cleared']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1918 + $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_failed']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1919 + $hc_not_detected = isset($_GET['hc_not_detected']) ? sanitize_text_field(urldecode(wp_unslash($_GET['hc_not_detected']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result notice after a nonce-checked purge
1631 1920
1632 1921 if ($hc_cleared) {
1633 1922 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1634 1923 echo '✅ <strong>Success!</strong> Purged hosting cache on: ' . esc_html($hc_cleared);
@@ -1643,8 +1932,9 @@
1643 1932 echo '<div class="notice notice-info inline" style="margin-top: 10px;"><p>';
1644 1933 echo 'ℹ️ No enabled hosting providers were detected on this server (' . esc_html($hc_not_detected) . ').';
1645 1934 echo '</p></div>';
1646 1935 }
1936 + // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1647 1937 }
1648 1938 ?>
1649 1939
1650 1940 <script>
@@ -1679,8 +1969,46 @@
1679 1969 setTimeout(function() { $msg.fadeOut(); }, 4000);
1680 1970 }
1681 1971 });
1682 1972 });
1973 +
1974 + // OTTO Cache TTL save
1975 + $('#metasync-otto-ttl-save-btn').on('click', function() {
1976 + var $btn = $(this);
1977 + var $msg = $('#metasync-otto-ttl-save-msg');
1978 + var ttl = parseInt($('#metasync-otto-cache-ttl').val(), 10);
1979 +
1980 + if (isNaN(ttl) || ttl < 30 || ttl > 1440) {
1981 + $msg.text('❌ TTL must be between 30 and 1440 minutes.').css('color', '#ef4444').show();
1982 + return;
1983 + }
1984 +
1985 + $btn.prop('disabled', true).text('Saving…');
1986 +
1987 + $.ajax({
1988 + url: ajaxurl,
1989 + type: 'POST',
1990 + data: {
1991 + action: 'metasync_save_otto_cache_ttl',
1992 + otto_cache_ttl_nonce: $('#metasync-otto-ttl-nonce').val(),
1993 + otto_cache_ttl: ttl,
1994 + },
1995 + success: function(response) {
1996 + if (response.success) {
1997 + $msg.text('✅ Saved').css('color', '#22c55e').show();
1998 + } else {
1999 + $msg.text('❌ ' + (response.data && response.data.message ? response.data.message : 'Save failed')).css('color', '#ef4444').show();
2000 + }
2001 + },
2002 + error: function() {
2003 + $msg.text('❌ Request failed').css('color', '#ef4444').show();
2004 + },
2005 + complete: function() {
2006 + $btn.prop('disabled', false).text('Save TTL');
2007 + setTimeout(function() { $msg.fadeOut(); }, 4000);
2008 + }
2009 + });
2010 + });
1683 2011 });
1684 2012 </script>
1685 2013 </div>
1686 2014
@@ -1698,9 +2026,9 @@
1698 2026 id="metasync-targeted-object-cache"
1699 2027 <?php checked('1', $targeted_cache_enabled); ?>
1700 2028 style="width: 16px; height: 16px; cursor: pointer; margin-top: 2px; flex-shrink: 0;" />
1701 2029 <span>
1702 - <span style="color: var(--dashboard-text-primary); font-weight: 500; display: block; margin-bottom: 4px;">Targeted Object Cache Purge</span>
2030 + <span style="color: var(--dashboard-text-primary); font-weight: 500; display: block; margin-bottom: 4px;">Targeted Object Cache Purge<?php Metasync::render_tooltip_icon('targeted_object_cache_purge', 'Leave this on. Refreshes only the pages OTTO changed instead of wiping your whole site\'s memory cache — safer/faster on large sites.'); ?></span>
1703 2031 <span style="color: var(--dashboard-text-secondary); font-size: 12px;">
1704 2032 When enabled, only the updated posts are evicted from the object cache (recommended for large sites).
1705 2033 When disabled, a full <code>wp_cache_flush()</code> is used instead.
1706 2034 </span>
@@ -1771,23 +2099,23 @@
1771 2099 </div>
1772 2100
1773 2101 <?php
1774 2102 // Display success/error messages
1775 - if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') {
1776 - $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0;
1777 - $url = isset($_GET['url']) ? urldecode(sanitize_text_field(wp_unslash($_GET['url']))) : '';
2103 + if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2104 + $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2105 + $url = isset($_GET['url']) ? urldecode(sanitize_text_field(wp_unslash($_GET['url']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
1778 2106
1779 2107 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
1780 2108 if (!empty($url)) {
1781 - echo '✅ <strong>Success!</strong> Cleared cache for URL: <code>' . esc_html($url) . '</code> (' . $cleared_count . ' entries)';
2109 + echo '✅ <strong>Success!</strong> Cleared cache for URL: <code>' . esc_html($url) . '</code> (' . intval( $cleared_count ) . ' entries)';
1782 2110 } else {
1783 - echo '✅ <strong>Success!</strong> Cleared entire transient cache (' . $cleared_count . ' entries)';
2111 + echo '✅ <strong>Success!</strong> Cleared entire transient cache (' . intval( $cleared_count ) . ' entries)';
1784 2112 }
1785 2113 echo '</p></div>';
1786 2114 }
1787 2115
1788 - if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') {
1789 - $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : 'An unknown error occurred.';
2116 + if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
2117 + $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : 'An unknown error occurred.'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce
1790 2118 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
1791 2119 echo '❌ <strong>Error:</strong> ' . esc_html($message);
1792 2120 echo '</p></div>';
1793 2121 }
@@ -1826,9 +2154,9 @@
1826 2154 <td>
1827 2155 <input type="url"
1828 2156 id="otto_cache_url"
1829 2157 name="otto_cache_url"
1830 - value="<?php echo isset($_GET['url']) ? esc_attr(urldecode(sanitize_text_field(wp_unslash($_GET['url'])))) : ''; ?>"
2158 + value="<?php echo isset($_GET['url']) ? esc_attr(urldecode(sanitize_text_field(wp_unslash($_GET['url'])))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only notice or prefill, the form submit carries a nonce ?>"
1831 2159 class="regular-text"
1832 2160 placeholder="https://example.com/page/"
1833 2161 required />
1834 2162 <p class="description" style="color: var(--dashboard-text-secondary);">Enter the full URL of the page whose cache you want to clear.</p>
@@ -1899,9 +2227,10 @@
1899 2227 /**
1900 2228 * AJAX handler for saving hosting cache settings
1901 2229 */
1902 2230 public function ajax_save_hosting_cache_settings() {
1903 - if (!isset($_POST['hosting_cache_nonce']) || !wp_verify_nonce($_POST['hosting_cache_nonce'], 'metasync_hosting_cache_nonce')) {
2231 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2232 + if (!isset($_POST['hosting_cache_nonce']) || !wp_verify_nonce(wp_unslash($_POST['hosting_cache_nonce']), 'metasync_hosting_cache_nonce')) {
1904 2233 wp_send_json_error(array('message' => 'Security check failed'));
1905 2234 return;
1906 2235 }
1907 2236
@@ -1922,9 +2251,10 @@
1922 2251 /**
1923 2252 * AJAX handler: save object cache behaviour settings
1924 2253 */
1925 2254 public function ajax_save_object_cache_settings() {
1926 - if (!isset($_POST['object_cache_nonce']) || !wp_verify_nonce($_POST['object_cache_nonce'], 'metasync_object_cache_nonce')) {
2255 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2256 + if (!isset($_POST['object_cache_nonce']) || !wp_verify_nonce(wp_unslash($_POST['object_cache_nonce']), 'metasync_object_cache_nonce')) {
1927 2257 wp_send_json_error(array('message' => 'Security check failed'));
1928 2258 return;
1929 2259 }
1930 2260
@@ -1938,12 +2268,52 @@
1938 2268 wp_send_json_success(array('message' => 'Object cache settings saved'));
1939 2269 }
1940 2270
1941 2271 /**
2272 + * Get OTTO Cache TTL value in minutes from execution settings.
2273 + *
2274 + * @return int
2275 + */
2276 + private function get_otto_cache_ttl_minutes() {
2277 + $execution_settings = get_option('metasync_execution_settings', array());
2278 + return isset($execution_settings['otto_cache_ttl']) ? absint($execution_settings['otto_cache_ttl']) : 30;
2279 + }
2280 +
2281 + /**
2282 + * AJAX handler for saving OTTO Cache TTL
2283 + */
2284 + public function ajax_save_otto_cache_ttl() {
2285 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2286 + if (!isset($_POST['otto_cache_ttl_nonce']) || !wp_verify_nonce(wp_unslash($_POST['otto_cache_ttl_nonce']), 'metasync_otto_cache_ttl_nonce')) {
2287 + wp_send_json_error(array('message' => 'Security check failed.'));
2288 + return;
2289 + }
2290 +
2291 + if (!current_user_can('manage_options')) {
2292 + wp_send_json_error(array('message' => 'Insufficient permissions.'));
2293 + return;
2294 + }
2295 +
2296 + $ttl = isset($_POST['otto_cache_ttl']) ? absint($_POST['otto_cache_ttl']) : 30;
2297 +
2298 + if ($ttl < 30 || $ttl > 1440) {
2299 + wp_send_json_error(array('message' => sprintf('%s Cache TTL must be between 30 and 1440 minutes.', Metasync::get_whitelabel_otto_name())));
2300 + return;
2301 + }
2302 +
2303 + $settings = get_option('metasync_execution_settings', array());
2304 + $settings['otto_cache_ttl'] = $ttl;
2305 + update_option('metasync_execution_settings', $settings);
2306 +
2307 + wp_send_json_success(array('message' => sprintf('%s Cache TTL saved.', Metasync::get_whitelabel_otto_name())));
2308 + }
2309 +
2310 + /**
1942 2311 * admin_post handler: purge WP Engine and Kinsta hosting-level caches
1943 2312 */
1944 2313 public function handle_purge_hosting_cache() {
1945 - if (!isset($_POST['hosting_cache_purge_nonce']) || !wp_verify_nonce($_POST['hosting_cache_purge_nonce'], 'metasync_hosting_cache_purge_nonce')) {
2314 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
2315 + if (!isset($_POST['hosting_cache_purge_nonce']) || !wp_verify_nonce(wp_unslash($_POST['hosting_cache_purge_nonce']), 'metasync_hosting_cache_purge_nonce')) {
1946 2316 wp_die('Security check failed');
1947 2317 }
1948 2318
1949 2319 if (!Metasync::current_user_has_plugin_access()) {
@@ -1963,9 +2333,9 @@
1963 2333 WpeCommon::purge_varnish_cache();
1964 2334 WpeCommon::purge_memcached();
1965 2335 $cleared[] = 'WP Engine';
1966 2336 } catch (Exception $e) {
1967 - error_log('MetaSync: WP Engine hosting cache purge failed - ' . $e->getMessage());
2337 + error_log('MetaSync: WP Engine hosting cache purge failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1968 2338 $failed[] = 'WP Engine';
1969 2339 }
1970 2340 } else {
1971 2341 $not_detected[] = 'WP Engine';
@@ -1978,9 +2348,9 @@
1978 2348 try {
1979 2349 KinstaCache::get_instance()->kinsta_cache_purge_full();
1980 2350 $cleared[] = 'Kinsta';
1981 2351 } catch (Exception $e) {
1982 - error_log('MetaSync: Kinsta hosting cache purge failed - ' . $e->getMessage());
2352 + error_log('MetaSync: Kinsta hosting cache purge failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1983 2353 $failed[] = 'Kinsta';
1984 2354 }
1985 2355 } else {
1986 2356 $not_detected[] = 'Kinsta';
@@ -2375,9 +2745,9 @@
2375 2745 # Initialize MetaSync API request class and trigger synchronization
2376 2746 (new Metasync_Sync_Requests())->SyncCustomerParams();
2377 2747 } catch (Exception $e) {
2378 2748 # Log any API request errors for debugging
2379 - error_log('Metasync API Error: ' . $e->getMessage());
2749 + error_log('Metasync API Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2380 2750 }
2381 2751 }
2382 2752
2383 2753 /*
@@ -2392,9 +2762,9 @@
2392 2762 # Initialize MetaSync API request class and trigger synchronization
2393 2763 (new Metasync_Sync_Requests())->SyncCustomerParams();
2394 2764 } catch (Exception $e) {
2395 2765 # Log any API request errors for debugging
2396 - error_log('Metasync API Error: ' . $e->getMessage());
2766 + error_log('Metasync API Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2397 2767 }
2398 2768 }
2399 2769
2400 2770 /**
@@ -2427,8 +2797,15 @@
2427 2797 // Handle form submissions
2428 2798 if (isset($_POST['metasync_media_optimization_nonce'])) {
2429 2799 check_admin_referer('metasync_save_media_optimization', 'metasync_media_optimization_nonce');
2430 2800
2801 + // Settings shape the whole site's conversion pipeline — restrict
2802 + // the save (and the reset) to site admins, not just anyone who can
2803 + // open the page via plugin_access_roles.
2804 + if (!current_user_can('manage_options')) {
2805 + wp_die(esc_html__('Sorry, you are not allowed to manage media optimization settings.', 'metasync'));
2806 + }
2807 +
2431 2808 // Handle reset to defaults
2432 2809 if (!empty($_POST['metasync_media_reset'])) {
2433 2810 $defaults = Metasync_Media_Settings::get_defaults();
2434 2811 Metasync_Media_Settings::save_settings($defaults);
@@ -2433,8 +2810,9 @@
2433 2810 $defaults = Metasync_Media_Settings::get_defaults();
2434 2811 Metasync_Media_Settings::save_settings($defaults);
2435 2812 $save_success = true;
2436 2813 } elseif (isset($_POST['metasync_media'])) {
2814 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Media_Settings::save_settings().
2437 2815 $input = wp_unslash($_POST['metasync_media']);
2438 2816 Metasync_Media_Settings::save_settings($input);
2439 2817 $save_success = true;
2440 2818 }
@@ -2466,8 +2844,15 @@
2466 2844 * Code Minification page callback
2467 2845 */
2468 2846 public function create_admin_code_minification_page()
2469 2847 {
2848 + // Settings shape the whole site's asset pipeline — restrict
2849 + // the save (and the reset) to site admins, not just anyone who can
2850 + // open the page via plugin_access_roles.
2851 + if (!empty($_POST) && !current_user_can('manage_options')) {
2852 + wp_die(esc_html__('Sorry, you are not allowed to manage code minification settings.', 'metasync'));
2853 + }
2854 +
2470 2855 $this->render_layout_open('Code Minification', 'code_minification', 'Minify CSS, JavaScript, and HTML to improve performance.');
2471 2856 // Load settings and compatibility classes
2472 2857 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-settings.php';
2473 2858 require_once plugin_dir_path(dirname(__FILE__)) . 'code-minification/class-minification-cache.php';
@@ -2484,8 +2869,9 @@
2484 2869 $defaults = Metasync_Minification_Settings::get_defaults();
2485 2870 Metasync_Minification_Settings::save_settings($defaults);
2486 2871 $save_success = true;
2487 2872 } elseif (isset($_POST['metasync_code_min'])) {
2873 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated and sanitised by Metasync_Minification_Settings::save_settings().
2488 2874 $input = (array) wp_unslash($_POST['metasync_code_min']);
2489 2875 Metasync_Minification_Settings::save_settings($input);
2490 2876 $save_success = true;
2491 2877 }
@@ -2518,11 +2904,40 @@
2518 2904 if (!$attachment_id) {
2519 2905 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2520 2906 }
2521 2907
2908 + // Ownership: under the replace strategy this permanently deletes the
2909 + // attachment's original file, so upload_files alone is not enough —
2910 + // the caller must be allowed to edit this specific attachment.
2911 + if (!current_user_can('edit_post', $attachment_id)) {
2912 + wp_send_json_error(__('Permission denied.', 'metasync'));
2913 + }
2914 +
2522 2915 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-settings.php';
2916 + require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-image-converter.php';
2523 2917 $settings = Metasync_Media_Settings::get_settings();
2524 2918
2919 + $file = get_attached_file($attachment_id);
2920 + $mime = get_post_mime_type($attachment_id);
2921 +
2922 + if (!$file || !file_exists($file)) {
2923 + wp_send_json_error(__('Optimization failed: file not found.', 'metasync'));
2924 + }
2925 +
2926 + $max_bytes = 10 * 1024 * 1024;
2927 + if (filesize($file) > $max_bytes) {
2928 + $size_mb = round(filesize($file) / 1024 / 1024, 1);
2929 + wp_send_json_error(sprintf(
2930 + /* translators: %s: file size in megabytes. */
2931 + __('Optimization skipped: file size (%s MB) exceeds the 10 MB safety limit to prevent memory issues.', 'metasync'),
2932 + $size_mb
2933 + ));
2934 + }
2935 +
2936 + if (!in_array($mime, ['image/jpeg', 'image/png'], true)) {
2937 + wp_send_json_error(__('Optimization failed: unsupported image format.', 'metasync'));
2938 + }
2939 +
2525 2940 $success = Metasync_Image_Converter::convert_attachment($attachment_id, $settings);
2526 2941
2527 2942 if ($success) {
2528 2943 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
@@ -2527,12 +2942,13 @@
2527 2942 if ($success) {
2528 2943 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
2529 2944 wp_send_json_success([
2530 2945 'status_html' => Metasync_Media_Library_List_Table::render_status_html($attachment_id),
2946 + 'can_revert' => Metasync_Image_Converter::can_revert($attachment_id),
2531 2947 ]);
2532 2948 }
2533 2949
2534 - wp_send_json_error(__('Optimization failed.', 'metasync'));
2950 + wp_send_json_error(__('Optimization failed: conversion could not be completed.', 'metasync'));
2535 2951 }
2536 2952
2537 2953 /**
2538 2954 * AJAX: Revert a single image.
@@ -2549,8 +2965,20 @@
2549 2965 if (!$attachment_id) {
2550 2966 wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
2551 2967 }
2552 2968
2969 + // Ownership: reverting deletes the attachment's converted files, so
2970 + // restrict it to users who may edit this specific attachment.
2971 + if (!current_user_can('edit_post', $attachment_id)) {
2972 + wp_send_json_error(__('Permission denied.', 'metasync'));
2973 + }
2974 +
2975 + // Replace-strategy conversions have no original to restore — reverting
2976 + // would delete the attachment's only file. Refuse up front, like bulk revert does.
2977 + if (!Metasync_Image_Converter::can_revert($attachment_id)) {
2978 + wp_send_json_error(__('This image cannot be reverted. The original file no longer exists (replace strategy).', 'metasync'));
2979 + }
2980 +
2553 2981 $success = Metasync_Image_Converter::revert_attachment($attachment_id);
2554 2982
2555 2983 if ($success) {
2556 2984 wp_send_json_success();
@@ -2602,8 +3030,15 @@
2602 3030 public function ajax_batch_progress()
2603 3031 {
2604 3032 check_ajax_referer('metasync_media_opt_nonce', 'nonce');
2605 3033
3034 + // Read-only progress/stats polling — the same capability that can
3035 + // open the media optimization page is enough, but a nonce alone
3036 + // never was.
3037 + if (!current_user_can('upload_files')) {
3038 + wp_send_json_error(__('Permission denied.', 'metasync'));
3039 + }
3040 +
2606 3041 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-batch-optimizer.php';
2607 3042 require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
2608 3043
2609 3044 $progress = Metasync_Media_Batch_Optimizer::get_progress();
@@ -2634,10 +3069,19 @@
2634 3069 $settings = Metasync_Media_Settings::get_settings();
2635 3070
2636 3071 $success = 0;
2637 3072 $failed = 0;
3073 + $denied = 0;
2638 3074
2639 3075 foreach ($ids as $id) {
3076 + // Per-attachment ownership: replace mode deletes originals, so
3077 + // upload_files alone must not allow converting images the caller
3078 + // does not own/cannot edit.
3079 + if (!current_user_can('edit_post', $id)) {
3080 + $denied++;
3081 + continue;
3082 + }
3083 +
2640 3084 if (Metasync_Image_Converter::convert_attachment($id, $settings)) {
2641 3085 $success++;
2642 3086 } else {
2643 3087 $failed++;
@@ -2646,12 +3090,81 @@
2646 3090
2647 3091 wp_send_json_success([
2648 3092 'success' => $success,
2649 3093 'failed' => $failed,
3094 + 'denied' => $denied,
2650 3095 ]);
2651 3096 }
2652 3097
2653 3098 /**
3099 + * AJAX: Bulk unoptimize (revert) selected images.
3100 + */
3101 + public function ajax_bulk_unoptimize_selected()
3102 + {
3103 + check_ajax_referer('metasync_media_opt_nonce', 'nonce');
3104 +
3105 + if (!current_user_can('upload_files')) {
3106 + wp_send_json_error(__('Permission denied.', 'metasync'));
3107 + }
3108 +
3109 + $ids = isset($_POST['ids']) ? array_map('absint', explode(',', sanitize_text_field(wp_unslash($_POST['ids'])))) : [];
3110 + $ids = array_filter($ids);
3111 +
3112 + if (empty($ids)) {
3113 + wp_send_json_error(__('No images selected.', 'metasync'));
3114 + }
3115 +
3116 + $success = 0;
3117 + $failed = 0;
3118 + $skipped = 0;
3119 + $denied = 0;
3120 + $errors = [];
3121 +
3122 + foreach ($ids as $id) {
3123 + // Per-attachment ownership: reverting deletes the attachment's
3124 + // converted files, so restrict it per image, not per role.
3125 + if (!current_user_can('edit_post', $id)) {
3126 + $denied++;
3127 + continue;
3128 + }
3129 +
3130 + $format = get_post_meta($id, '_metasync_converted_format', true);
3131 +
3132 + if (!$format) {
3133 + $skipped++;
3134 + continue;
3135 + }
3136 +
3137 + if (!Metasync_Image_Converter::can_revert($id)) {
3138 + $skipped++;
3139 + $file = get_attached_file($id);
3140 + $name = $file ? basename($file) : "ID {$id}";
3141 + /* translators: %s: attachment file name. */
3142 + $errors[] = sprintf(__('%s: skipped — original image unavailable.', 'metasync'), $name);
3143 + continue;
3144 + }
3145 +
3146 + if (Metasync_Image_Converter::revert_attachment($id)) {
3147 + $success++;
3148 + } else {
3149 + $failed++;
3150 + $file = get_attached_file($id);
3151 + $name = $file ? basename($file) : "ID {$id}";
3152 + /* translators: %s: attachment file name. */
3153 + $errors[] = sprintf(__('%s: revert failed (original file may not exist).', 'metasync'), $name);
3154 + }
3155 + }
3156 +
3157 + wp_send_json_success([
3158 + 'success' => $success,
3159 + 'failed' => $failed,
3160 + 'skipped' => $skipped,
3161 + 'denied' => $denied,
3162 + 'errors' => $errors,
3163 + ]);
3164 + }
3165 +
3166 + /**
2654 3167 * AJAX: Process one batch tick (browser-driven chaining).
2655 3168 */
2656 3169 public function ajax_process_batch_tick()
2657 3170 {
@@ -2670,8 +3183,49 @@
2670 3183 wp_send_json_success($progress);
2671 3184 }
2672 3185
2673 3186 /**
3187 + * AJAX: Delete an orphaned media record (attachment whose file is missing).
3188 + *
3189 + * Only deletes when the file is confirmed missing on disk, so valid
3190 + * attachments can never be removed through this endpoint.
3191 + */
3192 + public function ajax_delete_orphaned_image()
3193 + {
3194 + check_ajax_referer('metasync_media_opt_nonce', 'nonce');
3195 +
3196 + $attachment_id = isset($_POST['attachment_id']) ? absint($_POST['attachment_id']) : 0;
3197 + if (!$attachment_id) {
3198 + wp_send_json_error(__('Invalid attachment ID.', 'metasync'));
3199 + }
3200 +
3201 + if (!current_user_can('delete_post', $attachment_id)) {
3202 + wp_send_json_error(__('Permission denied.', 'metasync'));
3203 + }
3204 +
3205 + if (get_post_type($attachment_id) !== 'attachment') {
3206 + wp_send_json_error(__('Not an attachment.', 'metasync'));
3207 + }
3208 +
3209 + require_once plugin_dir_path(dirname(__FILE__)) . 'media-optimization/class-media-library-list-table.php';
3210 +
3211 + // Guard: only orphaned records (missing file) may be deleted here.
3212 + if (!Metasync_Media_Library_List_Table::is_file_missing($attachment_id)) {
3213 + wp_send_json_error(__('The image file exists; refusing to delete a valid attachment.', 'metasync'));
3214 + }
3215 +
3216 + $deleted = wp_delete_attachment($attachment_id, true);
3217 +
3218 + if ($deleted) {
3219 + wp_send_json_success([
3220 + 'stats' => Metasync_Media_Library_List_Table::get_stats(),
3221 + ]);
3222 + }
3223 +
3224 + wp_send_json_error(__('Failed to delete the orphaned media record.', 'metasync'));
3225 + }
3226 +
3227 + /**
2674 3228 * Cron handler: Process batch optimization tick.
2675 3229 */
2676 3230 public function handle_media_batch_cron()
2677 3231 {
@@ -2681,8 +3235,86 @@
2681 3235 Metasync_Media_Batch_Optimizer::process_batch_tick();
2682 3236 }
2683 3237
2684 3238 /**
3239 + * AJAX: Start SEO Restore batch.
3240 + */
3241 + public function ajax_seo_restore_start()
3242 + {
3243 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3244 +
3245 + if (!current_user_can('manage_options')) {
3246 + wp_send_json_error(__('Permission denied.', 'metasync'));
3247 + }
3248 +
3249 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3250 +
3251 + $progress = Metasync_Seo_Restore::start_batch();
3252 + wp_send_json_success($progress);
3253 + }
3254 +
3255 + /**
3256 + * AJAX: Cancel SEO Restore batch.
3257 + */
3258 + public function ajax_seo_restore_cancel()
3259 + {
3260 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3261 +
3262 + if (!current_user_can('manage_options')) {
3263 + wp_send_json_error(__('Permission denied.', 'metasync'));
3264 + }
3265 +
3266 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3267 +
3268 + Metasync_Seo_Restore::cancel_batch();
3269 + wp_send_json_success();
3270 + }
3271 +
3272 + /**
3273 + * AJAX: Get SEO Restore batch progress.
3274 + */
3275 + public function ajax_seo_restore_progress()
3276 + {
3277 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3278 +
3279 + if (!current_user_can('manage_options')) {
3280 + wp_send_json_error(__('Permission denied.', 'metasync'));
3281 + }
3282 +
3283 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3284 +
3285 + $progress = Metasync_Seo_Restore::get_progress();
3286 + wp_send_json_success($progress);
3287 + }
3288 +
3289 + /**
3290 + * AJAX: Process one SEO Restore tick (browser-driven chaining).
3291 + */
3292 + public function ajax_seo_restore_process_tick()
3293 + {
3294 + check_ajax_referer('metasync_seo_restore_nonce', 'nonce');
3295 +
3296 + if (!current_user_can('manage_options')) {
3297 + wp_send_json_error(__('Permission denied.', 'metasync'));
3298 + }
3299 +
3300 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3301 +
3302 + $progress = Metasync_Seo_Restore::process_ajax_tick();
3303 + wp_send_json_success($progress);
3304 + }
3305 +
3306 + /**
3307 + * Cron handler: Process SEO Restore batch tick.
3308 + */
3309 + public function handle_seo_restore_cron()
3310 + {
3311 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-restore.php';
3312 +
3313 + Metasync_Seo_Restore::process_batch_tick();
3314 + }
3315 +
3316 + /**
2685 3317 * Report Issue page callback
2686 3318 */
2687 3319 public function create_admin_report_issue_page()
2688 3320 {
@@ -2694,8 +3326,17 @@
2694 3326 * XML Sitemap page callback
2695 3327 */
2696 3328 public function create_admin_xml_sitemap_page()
2697 3329 {
3330 + // Every form action below mutates site-wide state — sitemap options,
3331 + // sitemap files on disk, robots.txt, other plugins' sitemap generators
3332 + // — so they are restricted to site admins, not just anyone who can
3333 + // open the page via plugin_access_roles. The nonce is CSRF
3334 + // protection, not authorization.
3335 + if (!empty($_POST) && !current_user_can('manage_options')) {
3336 + wp_die(esc_html__('Sorry, you are not allowed to manage sitemap settings.', 'metasync'));
3337 + }
3338 +
2698 3339 // Load sitemap generator class
2699 3340 require_once plugin_dir_path(dirname(__FILE__)) . 'sitemap/class-metasync-sitemap-generator.php';
2700 3341
2701 3342 $sitemap_generator = new Metasync_Sitemap_Generator();
@@ -2707,17 +3348,56 @@
2707 3348 } elseif (isset($_POST['redirect_tab']) && in_array($_POST['redirect_tab'], ['general', 'news', 'video'], true)) {
2708 3349 $active_tab = sanitize_text_field(wp_unslash($_POST['redirect_tab']));
2709 3350 }
2710 3351
3352 + // Handle Main Sitemap settings form submission
3353 + if (isset($_POST['metasync_sitemap_settings_nonce']) && isset($_POST['save_sitemap_settings'])) {
3354 + check_admin_referer('metasync_sitemap_settings_action', 'metasync_sitemap_settings_nonce');
3355 +
3356 + $sitemap_settings = [
3357 + '_configured' => true,
3358 + 'post_types' => array_map('sanitize_key', (array) ($_POST['sitemap_post_types'] ?? [])),
3359 + 'categories' => array_map('absint', (array) ($_POST['sitemap_categories'] ?? [])),
3360 + 'tags' => array_map('absint', (array) ($_POST['sitemap_tags'] ?? [])),
3361 + 'taxonomies' => array_map('sanitize_key', (array) ($_POST['sitemap_taxonomies'] ?? [])),
3362 + 'excluded_urls' => sanitize_textarea_field(wp_unslash($_POST['sitemap_excluded_urls'] ?? '')),
3363 + ];
3364 +
3365 + update_option('metasync_sitemap_settings', $sitemap_settings);
3366 +
3367 + // Regenerate sitemap with new content settings
3368 + $result = $sitemap_generator->generate_sitemap( true );
3369 + if (is_wp_error($result)) {
3370 + echo '<div class="notice notice-error"><p>' . esc_html(
3371 + /* translators: %s: error message. */
3372 + sprintf(__('Settings saved but sitemap generation failed: %s', 'metasync'), $result->get_error_message())
3373 + ) . '</p></div>';
3374 + } elseif (false === $result) {
3375 + echo '<div class="notice notice-error"><p>' . esc_html__('Settings saved but sitemap generation failed: the sitemap data could not be stored, so no sitemap is being served. Check your object cache and database write settings, then try again.', 'metasync') . '</p></div>';
3376 + } else {
3377 + echo '<div class="notice notice-success"><p>' . esc_html__('Sitemap content settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
3378 + }
3379 + }
3380 +
2711 3381 // Handle News Sitemap settings form submission
2712 3382 if (isset($_POST['metasync_news_sitemap_nonce']) && isset($_POST['save_news_sitemap'])) {
2713 3383 check_admin_referer('metasync_news_sitemap_action', 'metasync_news_sitemap_nonce');
2714 3384
3385 + // Build generic taxonomy filters
3386 + $news_taxonomies = [];
3387 + if (!empty($_POST['news_taxonomies']) && is_array($_POST['news_taxonomies'])) {
3388 + foreach (wp_unslash($_POST['news_taxonomies']) as $tax_name => $term_ids) {
3389 + $news_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) wp_unslash($term_ids));
3390 + }
3391 + }
3392 +
2715 3393 $news_settings = [
2716 3394 'enabled' => isset($_POST['news_enabled']),
2717 3395 'post_types' => array_map('sanitize_key', (array) ($_POST['news_post_types'] ?? ['post'])),
2718 3396 'categories' => array_map('absint', (array) ($_POST['news_categories'] ?? [])),
2719 3397 'tags' => array_map('absint', (array) ($_POST['news_tags'] ?? [])),
3398 + 'taxonomies' => $news_taxonomies,
3399 + 'excluded_urls' => sanitize_textarea_field(wp_unslash($_POST['news_excluded_urls'] ?? '')),
2720 3400 'publication_name' => sanitize_text_field(wp_unslash($_POST['publication_name'] ?? '')),
2721 3401 'publication_language' => sanitize_text_field(wp_unslash($_POST['publication_language'] ?? '')),
2722 3402 ];
2723 3403
@@ -2727,13 +3407,14 @@
2727 3407 update_option('metasync_news_sitemap_settings', $news_settings);
2728 3408
2729 3409 if ($news_settings['enabled']) {
2730 3410 $sitemap_generator->generate_news_sitemap();
3411 + update_option('metasync_sitemap_last_generated', current_time('mysql'));
2731 3412 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
2732 3413 } else {
2733 3414 // Also remove physical file if it exists
2734 3415 if (file_exists(ABSPATH . 'news-sitemap.xml')) {
2735 - @unlink(ABSPATH . 'news-sitemap.xml');
3416 + @wp_delete_file(ABSPATH . 'news-sitemap.xml');
2736 3417 }
2737 3418 echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
2738 3419 }
2739 3420 }
@@ -2741,12 +3422,26 @@
2741 3422 // Handle Video Sitemap settings form submission
2742 3423 if (isset($_POST['metasync_video_sitemap_nonce']) && isset($_POST['save_video_sitemap'])) {
2743 3424 check_admin_referer('metasync_video_sitemap_action', 'metasync_video_sitemap_nonce');
2744 3425
3426 + // Build generic taxonomy filters
3427 + $video_taxonomies = [];
3428 + if (!empty($_POST['video_taxonomies']) && is_array($_POST['video_taxonomies'])) {
3429 + foreach (wp_unslash($_POST['video_taxonomies']) as $tax_name => $term_ids) {
3430 + $video_taxonomies[sanitize_key($tax_name)] = array_map('absint', (array) wp_unslash($term_ids));
3431 + }
3432 + }
3433 +
2745 3434 $video_settings = [
2746 - 'enabled' => isset($_POST['video_enabled']),
2747 - 'post_types' => array_map('sanitize_key', (array) ($_POST['video_post_types'] ?? ['post', 'page'])),
2748 - 'auto_detect' => isset($_POST['auto_detect']),
3435 + 'enabled' => isset($_POST['video_enabled']),
3436 + 'post_types' => array_map('sanitize_key', (array) ($_POST['video_post_types'] ?? ['post', 'page'])),
3437 + 'auto_detect' => isset($_POST['auto_detect']),
3438 + 'taxonomies' => $video_taxonomies,
3439 + 'excluded_urls' => sanitize_textarea_field(wp_unslash($_POST['video_excluded_urls'] ?? '')),
3440 + // This array replaces the stored option wholesale, so every
3441 + // setting must be represented here or it is wiped on save.
3442 + 'video_url_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_url_meta_keys'] ?? '')),
3443 + 'video_thumbnail_meta_keys' => sanitize_textarea_field(wp_unslash($_POST['video_thumbnail_meta_keys'] ?? '')),
2749 3444 ];
2750 3445
2751 3446 // Always invalidate old cache before saving new settings
2752 3447 delete_transient('metasync_vsm_' . md5('video-sitemap.xml'));
@@ -2754,13 +3449,14 @@
2754 3449 update_option('metasync_video_sitemap_settings', $video_settings);
2755 3450
2756 3451 if ($video_settings['enabled']) {
2757 3452 $sitemap_generator->generate_video_sitemap();
3453 + update_option('metasync_sitemap_last_generated', current_time('mysql'));
2758 3454 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved and sitemap regenerated!', 'metasync') . '</p></div>';
2759 3455 } else {
2760 3456 // Also remove physical file if it exists
2761 3457 if (file_exists(ABSPATH . 'video-sitemap.xml')) {
2762 - @unlink(ABSPATH . 'video-sitemap.xml');
3458 + @wp_delete_file(ABSPATH . 'video-sitemap.xml');
2763 3459 }
2764 3460 echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap settings saved. Sitemap cache cleared.', 'metasync') . '</p></div>';
2765 3461 }
2766 3462 }
@@ -2803,8 +3499,17 @@
2803 3499 if ($sitemap_generator->generate_news_sitemap()) {
2804 3500 $extras[] = 'news';
2805 3501 }
2806 3502 }
3503 +
3504 + // Reclaim unreferenced memory between the news and video passes
3505 + // so the video generator starts with more headroom (they share
3506 + // one request). Each generator already releases its own object
3507 + // cache via clean_post_cache(), so no cache flush is needed here.
3508 + if (function_exists('gc_collect_cycles')) {
3509 + gc_collect_cycles();
3510 + }
3511 +
2807 3512 if (!empty($video_opts['enabled'])) {
2808 3513 if ($sitemap_generator->generate_video_sitemap()) {
2809 3514 $extras[] = 'video';
2810 3515 }
@@ -2809,17 +3514,30 @@
2809 3514 $extras[] = 'video';
2810 3515 }
2811 3516 }
2812 3517
3518 + // Same inter-pass GC before the main sitemap pass begins.
3519 + if (function_exists('gc_collect_cycles')) {
3520 + gc_collect_cycles();
3521 + }
3522 +
2813 3523 // Generate main sitemap (its index will include news/video since they now exist)
2814 - $result = $sitemap_generator->generate_sitemap();
3524 + $result = $sitemap_generator->generate_sitemap( true );
2815 3525
2816 3526 if (is_wp_error($result)) {
2817 - echo '<div class="notice notice-error"><p>' . esc_html($result->get_error_message()) . '</p></div>';
3527 + $error_msg = $result->get_error_message();
3528 + error_log('[MetaSync] Sitemap generation failed: ' . $error_msg); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3529 + echo '<div class="notice notice-error"><p>' . esc_html(
3530 + /* translators: %s: error message. */
3531 + sprintf(__('Sitemap generation failed: %s', 'metasync'), $error_msg)
3532 + ) . '</p></div>';
3533 + } elseif (false === $result) {
3534 + echo '<div class="notice notice-error"><p>' . esc_html__('Sitemap generation failed: the sitemap data could not be stored, so no sitemap is being served. Check your object cache and database write settings, then try again.', 'metasync') . '</p></div>';
2818 3535 } else {
2819 3536 $message = esc_html__('Sitemap generated successfully!', 'metasync');
2820 3537 if (!empty($extras)) {
2821 3538 $message .= ' ' . sprintf(
3539 + /* translators: %s: list of sitemap types. */
2822 3540 esc_html__('Also generated %s sitemap(s).', 'metasync'),
2823 3541 implode(' & ', $extras)
2824 3542 );
2825 3543 }
@@ -2848,8 +3566,38 @@
2848 3566 echo '<div class="notice notice-success"><p>' . esc_html__('Auto-update enabled!', 'metasync') . '</p></div>';
2849 3567 } elseif (isset($_POST['disable_auto_update'])) {
2850 3568 update_option('metasync_sitemap_auto_update', false);
2851 3569 echo '<div class="notice notice-success"><p>' . esc_html__('Auto-update disabled!', 'metasync') . '</p></div>';
3570 + } elseif (isset($_POST['delete_general_sitemap'])) {
3571 + $deleted = $sitemap_generator->delete_sitemap('general');
3572 +
3573 + if ($deleted) {
3574 + // Disable auto-update only when the general sitemap is removed
3575 + update_option('metasync_sitemap_auto_update', false);
3576 + // Re-enable WP core sitemap only if no other MetaSync sitemaps remain
3577 + if (!$sitemap_generator->sitemap_exists()) {
3578 + delete_option('metasync_disable_wp_sitemap');
3579 + }
3580 + echo '<div class="notice notice-success"><p>' . esc_html__('General sitemap deleted successfully!', 'metasync') . '</p></div>';
3581 + } else {
3582 + echo '<div class="notice notice-error"><p>' . esc_html__('Failed to delete general sitemap. The files may not exist or are not writable.', 'metasync') . '</p></div>';
3583 + }
3584 + } elseif (isset($_POST['delete_news_sitemap'])) {
3585 + $deleted = $sitemap_generator->delete_sitemap('news');
3586 +
3587 + if ($deleted) {
3588 + echo '<div class="notice notice-success"><p>' . esc_html__('News sitemap deleted successfully!', 'metasync') . '</p></div>';
3589 + } else {
3590 + echo '<div class="notice notice-error"><p>' . esc_html__('Failed to delete news sitemap. The file may not exist or is not writable.', 'metasync') . '</p></div>';
3591 + }
3592 + } elseif (isset($_POST['delete_video_sitemap'])) {
3593 + $deleted = $sitemap_generator->delete_sitemap('video');
3594 +
3595 + if ($deleted) {
3596 + echo '<div class="notice notice-success"><p>' . esc_html__('Video sitemap deleted successfully!', 'metasync') . '</p></div>';
3597 + } else {
3598 + echo '<div class="notice notice-error"><p>' . esc_html__('Failed to delete video sitemap. The file may not exist or is not writable.', 'metasync') . '</p></div>';
3599 + }
2852 3600 } elseif (isset($_POST['delete_sitemap'])) {
2853 3601 // Delete all sitemaps: main + news + video (handled by delete_sitemap)
2854 3602 $deleted = $sitemap_generator->delete_sitemap();
2855 3603
@@ -2855,8 +3603,10 @@
2855 3603
2856 3604 if ($deleted) {
2857 3605 // Also disable auto-update when deleting
2858 3606 update_option('metasync_sitemap_auto_update', false);
3607 + // Re-enable WP core sitemap so the site isn't left with zero sitemaps
3608 + delete_option('metasync_disable_wp_sitemap');
2859 3609 echo '<div class="notice notice-success"><p>' . esc_html__('All sitemaps deleted successfully!', 'metasync') . '</p></div>';
2860 3610 } else {
2861 3611 echo '<div class="notice notice-error"><p>' . esc_html__('Failed to delete sitemaps. The files may not exist or are not writable.', 'metasync') . '</p></div>';
2862 3612 }
@@ -2878,8 +3628,17 @@
2878 3628 $last_generated = $sitemap_generator->get_last_generated_time();
2879 3629 $auto_update_enabled = get_option('metasync_sitemap_auto_update', false);
2880 3630 $active_sitemap_plugins = $sitemap_generator->check_active_sitemap_plugins();
2881 3631
3632 + // Main sitemap content settings
3633 + $sitemap_settings = get_option('metasync_sitemap_settings', [
3634 + 'post_types' => [],
3635 + 'categories' => [],
3636 + 'tags' => [],
3637 + 'taxonomies' => [],
3638 + 'excluded_urls' => '',
3639 + ]);
3640 +
2882 3641 // News and video sitemap settings for tabs
2883 3642 $news_settings = get_option('metasync_news_sitemap_settings', [
2884 3643 'enabled' => false,
2885 3644 'post_types' => ['post'],
@@ -2884,15 +3643,19 @@
2884 3643 'enabled' => false,
2885 3644 'post_types' => ['post'],
2886 3645 'categories' => [],
2887 3646 'tags' => [],
3647 + 'taxonomies' => [],
3648 + 'excluded_urls' => '',
2888 3649 'publication_name' => '',
2889 3650 'publication_language' => '',
2890 3651 ]);
2891 3652 $video_settings = get_option('metasync_video_sitemap_settings', [
2892 - 'enabled' => false,
2893 - 'post_types' => ['post', 'page'],
2894 - 'auto_detect' => true,
3653 + 'enabled' => false,
3654 + 'post_types' => ['post', 'page'],
3655 + 'auto_detect' => true,
3656 + 'taxonomies' => [],
3657 + 'excluded_urls' => '',
2895 3658 ]);
2896 3659
2897 3660 // Load view
2898 3661 require_once plugin_dir_path(dirname(__FILE__)) . 'views/metasync-xml-sitemap.php';
@@ -2963,47 +3726,8 @@
2963 3726 Metasync_Admin_Pages::get_instance($this)->create_admin_breadcrumbs_page();
2964 3727 }
2965 3728
2966 3729 /**
2967 - * Google Instant Index Setting page callback
2968 - */
2969 - public function create_admin_google_instant_index_page()
2970 - {
2971 - $this->render_layout_open('Instant Indexing', 'instant_index', 'Submit URLs to Google for instant indexing via the Indexing API.');
2972 -
2973 - // Render shared Google Index credentials section
2974 - if (!function_exists('google_index_direct')) {
2975 - if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) {
2976 - require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
2977 - } else {
2978 - error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php');
2979 - return;
2980 - }
2981 - }
2982 - $google_index = google_index_direct();
2983 - $service_info = $google_index->get_service_account_info();
2984 - $is_configured = !isset($service_info['error']);
2985 -
2986 - $saved_json_display = $is_configured ? $google_index->get_redacted_config_json() : '';
2987 -
2988 - include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-index-api-settings.php';
2989 -
2990 - // Render post types selection with save form
2991 - $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
2992 - $post_types_settings = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
2993 - ?>
2994 - <form method="POST" action="">
2995 - <?php include plugin_dir_path(dirname(__FILE__)) . 'views/metasync-google-instant-post-types.php'; ?>
2996 - <div class="dashboard-card" style="padding: 20px;">
2997 - <?php submit_button('Save Post Types', 'primary', 'submit', false, array('class' => 'button button-primary')); ?>
2998 - </div>
2999 - </form>
3000 - <?php
3001 -
3002 - $this->render_layout_close();
3003 - }
3004 -
3005 - /**
3006 3730 * Google Console page callback
3007 3731 */
3008 3732 public function create_admin_google_console_page()
3009 3733 {
@@ -3026,16 +3750,8 @@
3026 3750 $this->render_layout_close();
3027 3751 }
3028 3752
3029 3753 /**
3030 - * General Options page callback
3031 - */
3032 - public function create_admin_optimal_settings_page()
3033 - {
3034 - Metasync_Admin_Pages::get_instance($this)->create_admin_optimal_settings_page();
3035 - }
3036 -
3037 - /**
3038 3754 * Global Options page callback
3039 3755 */
3040 3756 public function create_admin_global_settings_page()
3041 3757 {
@@ -3067,16 +3783,8 @@
3067 3783 Metasync_Admin_Pages::get_instance($this)->create_admin_seo_controls_page();
3068 3784 }
3069 3785
3070 3786 /**
3071 - * Site Optimal Settings page callback
3072 - */
3073 - public function optimization_settings_options()
3074 - {
3075 - Metasync_Admin_Pages::get_instance($this)->optimization_settings_options();
3076 - }
3077 -
3078 - /**
3079 3787 * redirection page callback with tabs
3080 3788 */
3081 3789 public function create_admin_redirections_page()
3082 3790 {
@@ -3103,10 +3811,11 @@
3103 3811 }
3104 3812 delete_transient( Metasync_CPU_Monitor::DEFER_NOTICE_TRANSIENT );
3105 3813 echo '<div class="notice notice-warning is-dismissible"><p>';
3106 3814 printf(
3107 - /* translators: 1: current load, 2: threshold, 3: core count */
3108 - esc_html__( 'MetaSync: Batch processing was deferred — server CPU load (%1$s) exceeded the threshold (%2$s on %3$s cores). Processing will resume automatically.', 'metasync' ),
3815 + /* translators: 1: plugin name, 2: current load, 3: threshold, 4: core count */
3816 + esc_html__( '%1$s: Batch processing was deferred — server CPU load (%2$s) exceeded the threshold (%3$s on %4$s cores). Processing will resume automatically.', 'metasync' ),
3817 + esc_html( Metasync::get_effective_plugin_name() ),
3109 3818 esc_html( $data['load'] ),
3110 3819 esc_html( $data['threshold'] ),
3111 3820 esc_html( $data['cores'] )
3112 3821 );
@@ -3124,9 +3833,10 @@
3124 3833 if (!get_transient('metasync_llms_conflict')) {
3125 3834 return;
3126 3835 }
3127 3836 echo '<div class="notice notice-info is-dismissible"><p>';
3128 - echo esc_html__('Note: Another SEO plugin (Yoast, Rank Math, or AIOSEO) may also be generating /llms.txt. MetaSync\'s version takes priority when enabled.', 'metasync');
3837 + /* translators: %s: effective plugin name (whitelabel-aware). */
3838 + echo esc_html(sprintf(__('Note: Another SEO plugin (Yoast, Rank Math, or AIOSEO) may also be generating /llms.txt. %s\'s version takes priority when enabled.', 'metasync'), Metasync::get_effective_plugin_name()));
3129 3839 echo '</p></div>';
3130 3840 }
3131 3841
3132 3842 /**
@@ -3195,8 +3905,17 @@
3195 3905 public function ajax_restore_robots_backup()
3196 3906 {
3197 3907 Metasync_Admin_Ajax::instance()->ajax_restore_robots_backup();
3198 3908 }
3909 +
3910 + /**
3911 + * AJAX handler to fetch a paginated page of robots.txt backups
3912 + */
3913 + public function ajax_get_robots_backups()
3914 + {
3915 + Metasync_Admin_Ajax::instance()->ajax_get_robots_backups();
3916 + }
3917 +
3199 3918 public function ajax_create_redirect_from_404()
3200 3919 {
3201 3920 Metasync_Admin_Ajax::instance()->ajax_create_redirect_from_404();
3202 3921 }
@@ -3224,9 +3943,9 @@
3224 3943 {
3225 3944 register_rest_route('metasync/v1', '/ping', array(
3226 3945 'methods' => array('GET', 'POST'),
3227 3946 'callback' => array($this, 'handle_ping_rest_endpoint'),
3228 - 'permission_callback' => '__return_true', // Allow public access
3947 + 'permission_callback' => '__return_true',
3229 3948 'args' => array(
3230 3949 'test' => array(
3231 3950 'description' => 'Optional test parameter',
3232 3951 'type' => 'string',
@@ -3308,17 +4027,17 @@
3308 4027 return;
3309 4028 }
3310 4029
3311 4030 // Get pagination parameters
3312 - $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1;
3313 - $per_page = 10;
4031 + $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
4032 + $per_page = Metasync_Per_Page_Helper::resolve('sync_log', 10);
3314 4033 $offset = ($page - 1) * $per_page;
3315 4034
3316 4035 // Get filters
3317 4036 $filters = [
3318 4037 // UI exposes date_range and status only. We compute date_from/date_to based on date_range
3319 - 'date_range' => isset($_GET['date_range']) ? sanitize_text_field(wp_unslash($_GET['date_range'])) : '',
3320 - 'status' => isset($_GET['status']) ? sanitize_text_field(wp_unslash($_GET['status'])) : '',
4038 + 'date_range' => isset($_GET['date_range']) ? sanitize_text_field(wp_unslash($_GET['date_range'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
4039 + 'status' => isset($_GET['status']) ? sanitize_text_field(wp_unslash($_GET['status'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log
3321 4040 ];
3322 4041
3323 4042 // Map date_range to concrete date_from/date_to for DB queries
3324 4043 $date_range = $filters['date_range'];
@@ -3327,28 +4046,28 @@
3327 4046 $date_to = '';
3328 4047
3329 4048 if (!empty($date_range)) {
3330 4049 // End boundary is now by default
3331 - $date_to = date('Y-m-d H:i:s', $wp_now_ts);
4050 + $date_to = gmdate('Y-m-d H:i:s', $wp_now_ts);
3332 4051
3333 4052 if ($date_range === 'today') {
3334 4053 $start_ts = strtotime('today', $wp_now_ts);
3335 - $date_from = date('Y-m-d H:i:s', $start_ts);
4054 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3336 4055 } elseif ($date_range === 'yesterday') {
3337 4056 $start_ts = strtotime('yesterday', $wp_now_ts);
3338 4057 $end_ts = strtotime('today', $wp_now_ts) - 1; // end of yesterday
3339 - $date_from = date('Y-m-d H:i:s', $start_ts);
3340 - $date_to = date('Y-m-d H:i:s', $end_ts);
4058 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
4059 + $date_to = gmdate('Y-m-d H:i:s', $end_ts);
3341 4060 } elseif ($date_range === 'this_week') {
3342 4061 $start_of_week = (int) get_option('start_of_week', 1); // 0=Sun, 1=Mon
3343 - $day_of_week = (int) date('w', $wp_now_ts); // 0=Sun..6=Sat
4062 + $day_of_week = (int) gmdate('w', $wp_now_ts); // 0=Sun..6=Sat
3344 4063 // Convert start_of_week to PHP's 0..6 where 0=Sunday
3345 4064 $delta_days = ($day_of_week - $start_of_week + 7) % 7;
3346 4065 $start_ts = strtotime('-' . $delta_days . ' days', strtotime('today', $wp_now_ts));
3347 - $date_from = date('Y-m-d H:i:s', $start_ts);
4066 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3348 4067 } elseif ($date_range === 'this_month') {
3349 - $start_ts = strtotime(date('Y-m-01 00:00:00', $wp_now_ts));
3350 - $date_from = date('Y-m-d H:i:s', $start_ts);
4068 + $start_ts = strtotime(gmdate('Y-m-01 00:00:00', $wp_now_ts));
4069 + $date_from = gmdate('Y-m-d H:i:s', $start_ts);
3351 4070 } elseif ($date_range === 'all') {
3352 4071 // no bounds
3353 4072 }
3354 4073 }
@@ -3375,12 +4094,24 @@
3375 4094 ?>
3376 4095 <div class="dashboard-card">
3377 4096 <div class="sync-log-header">
3378 4097 <div class="sync-log-title-section">
3379 - <h2>Changes Log</h2>
3380 - <p style="color: var(--dashboard-text-secondary); margin-bottom: 0;">
3381 - Recent content synchronizations from external tools.
3382 - <span style="margin-left:8px; font-size:12px; opacity:.75;">Records are automatically removed after 90 days.</span>
4098 + <?php
4099 + // The page title and description are already rendered by
4100 + // render_layout_open() above. Repeating them here printed
4101 + // "Changes Log" twice on screen, so the card keeps only the
4102 + // retention hint — the one piece of copy the layout header
4103 + // does not carry.
4104 + ?>
4105 + <p style="color: var(--dashboard-text-secondary); margin-top: 0; margin-bottom: 0;">
4106 + <?php
4107 + // No opacity here: this used to be a de-emphasised
4108 + // aside trailing a full-contrast sentence, but it is
4109 + // now the card's only copy. At 12px/.75 it composites
4110 + // to 4.28:1 on the card background, under the 4.5:1
4111 + // AA floor; at full strength it reads 6.52:1.
4112 + ?>
4113 + <span style="font-size:13px;">Records are automatically removed after 90 days.</span>
3383 4114 </p>
3384 4115 </div>
3385 4116
3386 4117 <!-- Filters + Clear Log button - Right aligned -->
@@ -3389,10 +4120,10 @@
3389 4120 style="background:#dc3545;color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:13px;"
3390 4121 data-nonce="<?php echo esc_attr(wp_create_nonce('metasync_clear_sync_log')); ?>">
3391 4122 🗑 Clear Log
3392 4123 </button>
3393 - <form method="get" class="sync-filters-form" onchange="this.submit()">
3394 - <input type="hidden" name="page" value="<?php echo esc_attr($_GET['page']); ?>">
4124 + <form method="get" class="sync-filters-form" onchange="this.submit()" style="display:flex;flex-direction:row;align-items:center;gap:12px;flex-wrap:nowrap;">
4125 + <input type="hidden" name="page" value="<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>">
3395 4126
3396 4127 <select name="date_range" class="sync-filter-select">
3397 4128 <option value="all" <?php selected($filters['date_range'] ?? 'all', 'all'); ?>> All Time</option>
3398 4129 <option value="today" <?php selected($filters['date_range'] ?? '', 'today'); ?>>Today</option>
@@ -3414,9 +4145,9 @@
3414 4145 <div class="sync-log-list">
3415 4146 <?php if (empty($sync_records)): ?>
3416 4147 <div class="sync-log-empty">
3417 4148 <div class="sync-log-empty-icon"><span class="dashicons dashicons-media-default" style="font-size:48px;width:48px;height:48px;color:var(--dashboard-text-secondary);"></span></div>
3418 - <h3>No sync records found</h3>
4149 + <h2>No sync records found</h2>
3419 4150 <p>Sync records will appear here when content/pages receive new updates.</p>
3420 4151 </div>
3421 4152 <?php else: ?>
3422 4153 <?php foreach ($sync_records as $record): ?>
@@ -3433,9 +4164,9 @@
3433 4164 <a href="<?php echo esc_url($record->url); ?>" target="_blank" rel="noopener" title="Open URL" style="margin-left:8px; text-decoration:none;"><span class="dashicons dashicons-external" style="font-size:14px;width:14px;height:14px;vertical-align:middle;"></span></a>
3434 4165 <?php endif; ?>
3435 4166 </div>
3436 4167 <div class="sync-log-meta">
3437 - <?php echo $this->time_elapsed_string($record->created_at); ?>
4168 + <?php echo esc_html( $this->time_elapsed_string($record->created_at) ); ?>
3438 4169 <?php if (!empty($record->source)): ?>
3439 4170 &nbsp;·&nbsp;<span style="opacity:.7;"><?php echo esc_html($record->source); ?></span>
3440 4171 <?php endif; ?>
3441 4172 </div>
@@ -3441,19 +4172,20 @@
3441 4172 </div>
3442 4173 </div>
3443 4174
3444 4175 <div class="sync-log-status" style="display:flex;align-items:center;gap:8px;">
3445 - <?php if ($record->status === 'published' || $record->status === 'publish'): ?>
3446 - <span class="sync-status-badge sync-status-published">
3447 - <span class="sync-status-icon">✓</span>
3448 - Published
3449 - </span>
3450 - <?php else: ?>
3451 - <span class="sync-status-badge sync-status-draft">
3452 - <span class="sync-status-icon">i</span>
3453 - Draft
3454 - </span>
3455 - <?php endif; ?>
4176 + <?php
4177 + $st = (string) $record->status;
4178 + $b_label = ucfirst($st); $b_bg = '#6b7280'; $b_icon = 'dashicons-info-outline';
4179 + if ($st === 'published' || $st === 'publish' || $st === 'success' || $st === 'partial') { $b_label = 'Published'; $b_bg = '#16a34a'; $b_icon = 'dashicons-yes'; }
4180 + elseif ($st === 'updated') { $b_label = 'Updated'; $b_bg = '#0d9488'; $b_icon = 'dashicons-update'; }
4181 + elseif ($st === 'failed' || $st === 'conflict' || $st === 'locked') { $b_label = 'Not imported'; $b_bg = '#64748b'; $b_icon = 'dashicons-minus'; }
4182 + elseif ($st === 'draft') { $b_label = 'Draft'; $b_bg = '#6b7280'; $b_icon = 'dashicons-info-outline'; }
4183 + ?>
4184 + <span class="sync-status-badge" style="display:inline-flex;align-items:center;gap:4px;background:<?php echo esc_attr($b_bg); ?>;color:#fff;padding:3px 10px;border-radius:12px;font-size:12px;font-weight:600;line-height:1;">
4185 + <span class="dashicons <?php echo esc_attr($b_icon); ?>" style="font-size:14px;width:14px;height:14px;line-height:14px;"></span>
4186 + <?php echo esc_html($b_label); ?>
4187 + </span>
3456 4188 <?php if ($record->source === 'MCP Client'): ?>
3457 4189 <button type="button"
3458 4190 class="metasync-rollback-btn"
3459 4191 data-id="<?php echo esc_attr($record->id); ?>"
@@ -3469,30 +4201,32 @@
3469 4201 <?php endif; ?>
3470 4202 </div>
3471 4203
3472 4204 <!-- Pagination -->
3473 - <?php if ($total_pages > 1): ?>
3474 - <div class="sync-log-pagination">
3475 - <div class="sync-log-pagination-info">
3476 - Total records: <?php echo $total_records; ?> | Showing <?php echo $offset + 1; ?>-<?php echo min($offset + $per_page, $total_records); ?>
3477 - </div>
4205 + <div class="sync-log-pagination">
4206 + <div class="sync-log-pagination-info">
4207 + Total records: <?php echo intval( $total_records ); ?><?php if ($total_records > 0): ?> | Showing <?php echo intval( $offset ) + 1; ?>-<?php echo intval( min($offset + $per_page, $total_records) ); ?><?php endif; ?>
4208 + <?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper returns a complete escaped selector element.
4209 + echo Metasync_Per_Page_Helper::render_selector('sync_log', absint($per_page)); ?>
4210 + </div>
3478 4211
4212 + <?php if ($total_pages > 1): ?>
3479 4213 <div class="sync-log-pagination-controls">
3480 4214 <?php if ($page > 1): ?>
3481 - <a href="?page=<?php echo esc_attr($_GET['page']); ?>&paged=<?php echo $page - 1; ?><?php echo $this->build_filter_query_string($filters); ?>" class="sync-pagination-btn">‹</a>
4215 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $page ) - 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">‹</a>
3482 4216 <?php endif; ?>
3483 4217
3484 4218 <?php for ($i = max(1, $page - 2); $i <= min($total_pages, $page + 2); $i++): ?>
3485 - <a href="?page=<?php echo esc_attr($_GET['page']); ?>&paged=<?php echo $i; ?><?php echo $this->build_filter_query_string($filters); ?>"
3486 - class="sync-pagination-btn <?php echo $i === $page ? 'active' : ''; ?>"><?php echo $i; ?></a>
4219 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $i ); ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>"
4220 + class="sync-pagination-btn <?php echo $i === $page ? 'active' : ''; ?>"><?php echo intval( $i ); ?></a>
3487 4221 <?php endfor; ?>
3488 4222
3489 4223 <?php if ($page < $total_pages): ?>
3490 - <a href="?page=<?php echo esc_attr($_GET['page']); ?>&paged=<?php echo $page + 1; ?><?php echo $this->build_filter_query_string($filters); ?>" class="sync-pagination-btn">›</a>
4224 + <a href="?page=<?php echo esc_attr(sanitize_key(wp_unslash($_GET['page']))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter and pagination values for the sync log ?>&paged=<?php echo intval( $page ) + 1; ?><?php echo esc_html( $this->build_filter_query_string($filters) ); ?>" class="sync-pagination-btn">›</a>
3491 4225 <?php endif; ?>
3492 4226 </div>
3493 - </div>
3494 - <?php endif; ?>
4227 + <?php endif; ?>
4228 + </div>
3495 4229 </div>
3496 4230 <?php $this->render_layout_close(); ?>
3497 4231
3498 4232 <script>
@@ -3573,8 +4307,19 @@
3573 4307 if (!empty($value)) {
3574 4308 $query_parts[] = $key . '=' . urlencode($value);
3575 4309 }
3576 4310 }
4311 +
4312 + // Preserve the user-selected results-per-page value across page
4313 + // navigation so a non-default page size survives clicking a page link.
4314 + $per_page_key = Metasync_Per_Page_Helper::request_key('sync_log');
4315 + if (isset($_GET[$per_page_key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4316 + $per_page = (int) $_GET[$per_page_key]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed per-page value for links
4317 + if (in_array($per_page, Metasync_Per_Page_Helper::allowed_values(), true)) {
4318 + $query_parts[] = $per_page_key . '=' . $per_page;
4319 + }
4320 + }
4321 +
3577 4322 return !empty($query_parts) ? '&' . implode('&', $query_parts) : '';
3578 4323 }
3579 4324
3580 4325 /**
@@ -3748,18 +4493,8 @@
3748 4493 {
3749 4494 Metasync_Connect_Manager::instance()->handle_session_management_early();
3750 4495 }
3751 4496
3752 - /**
3753 - * @deprecated 2.5.12 Use Metasync_Auth_Manager instead of sessions for authentication
3754 - */
3755 - private function safe_session_start() {
3756 - // This method is deprecated and no longer used
3757 - // Authentication now uses Metasync_Auth_Manager with WordPress transients and user meta
3758 - _deprecated_function(__METHOD__, '2.5.12', 'Metasync_Auth_Manager');
3759 - return Metasync_Session_Helper::safe_start();
3760 - }
3761 -
3762 4497 private function handle_whitelabel_session_logic()
3763 4498 {
3764 4499 Metasync_Connect_Manager::instance()->handle_whitelabel_session_logic();
3765 4500 }
@@ -3805,8 +4540,9 @@
3805 4540 $stats = Metasync_CPU_Monitor::get_stats();
3806 4541 $per_core_threshold = Metasync_CPU_Monitor::get_per_core_threshold();
3807 4542 $cores = Metasync_CPU_Monitor::get_cpu_core_count();
3808 4543 $effective_threshold = Metasync_CPU_Monitor::get_effective_threshold();
4544 + $detection_reliable = Metasync_CPU_Monitor::is_core_detection_reliable();
3809 4545 ?>
3810 4546 <div style="background: var(--dashboard-card-bg); padding: 20px; border-radius: 8px;">
3811 4547 <!-- CPU Cores Detected -->
3812 4548 <div style="margin-bottom: 24px;">
@@ -3813,12 +4549,20 @@
3813 4549 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
3814 4550 CPU Cores Detected
3815 4551 </label>
3816 4552 <div style="padding: 10px 12px; background: var(--dashboard-input-bg); border: 1px solid var(--dashboard-border); border-radius: 6px; color: var(--dashboard-text-secondary);">
3817 - <strong><?php echo intval($cores); ?></strong> core<?php echo $cores !== 1 ? 's' : ''; ?>
4553 + <?php if ($detection_reliable) : ?>
4554 + <strong><?php echo intval($cores); ?></strong> core<?php echo $cores !== 1 ? 's' : ''; ?>
4555 + <?php else : ?>
4556 + <strong>Not detected</strong>
4557 + <?php endif; ?>
3818 4558 </div>
3819 4559 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
3820 - Automatically detected on this system.
4560 + <?php if ($detection_reliable) : ?>
4561 + Automatically detected on this system.
4562 + <?php else : ?>
4563 + Core detection is not available on this hosting environment.
4564 + <?php endif; ?>
3821 4565 </p>
3822 4566 </div>
3823 4567
3824 4568 <!-- Per-Core Load Threshold -->
@@ -3824,8 +4568,9 @@
3824 4568 <!-- Per-Core Load Threshold -->
3825 4569 <div style="margin-bottom: 24px;">
3826 4570 <label for="cpu_load_per_core_threshold" style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
3827 4571 Per-Core Load Threshold
4572 + <?php Metasync::render_tooltip_icon('cpu_per_core_load_threshold', 'How busy each CPU core can get before the plugin pauses background SEO syncing. Lower = more cautious. Most sites leave this at 2.0.'); ?>
3828 4573 </label>
3829 4574 <input type="number"
3830 4575 id="cpu_load_per_core_threshold"
3831 4576 name="metasync_options[performance][cpu_load_per_core_threshold]"
@@ -3845,9 +4590,9 @@
3845 4590 <label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--dashboard-text);">
3846 4591 Effective Threshold
3847 4592 </label>
3848 4593 <div style="padding: 10px 12px; background: var(--dashboard-input-bg); border: 1px solid var(--dashboard-border); border-radius: 6px; color: var(--dashboard-text-secondary);">
3849 - <strong id="effective_threshold_value"><?php echo round($effective_threshold, 2); ?></strong>
4594 + <strong id="effective_threshold_value"><?php echo esc_html((string) round($effective_threshold, 2)); ?></strong>
3850 4595 </div>
3851 4596 <p style="margin: 8px 0 0 0; font-size: 12px; color: var(--dashboard-text-secondary);">
3852 4597 Calculated as: cores × per-core threshold
3853 4598 </p>
@@ -3865,13 +4610,13 @@
3865 4610 <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo intval($stats['deferrals']); ?></div>
3866 4611 </div>
3867 4612 <div>
3868 4613 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Max Load Observed</div>
3869 - <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo round($stats['max_load'], 2); ?></div>
4614 + <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo esc_html((string) round($stats['max_load'], 2)); ?></div>
3870 4615 </div>
3871 4616 <div>
3872 4617 <div style="font-size: 12px; color: var(--dashboard-text-secondary); margin-bottom: 4px;">Average Load</div>
3873 - <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo round($stats['avg_load'], 2); ?></div>
4618 + <div style="font-size: 18px; font-weight: 600; color: var(--dashboard-text);"><?php echo esc_html((string) round($stats['avg_load'], 2)); ?></div>
3874 4619 </div>
3875 4620 </div>
3876 4621 </div>
3877 4622
@@ -4377,88 +5122,8 @@
4377 5122
4378 5123 /**
4379 5124 * Get the settings option array and print one of its values
4380 5125 */
4381 - public function no_index_posts_callback()
4382 - {
4383 - Metasync_Settings_Fields::instance()->no_index_posts_callback();
4384 - }
4385 -
4386 - /**
4387 - * Get the settings option array and print one of its values
4388 - */
4389 - public function no_follow_links_callback()
4390 - {
4391 - Metasync_Settings_Fields::instance()->no_follow_links_callback();
4392 - }
4393 -
4394 - /**
4395 - * Get the settings option array and print one of its values
4396 - */
4397 - public function open_external_links_callback()
4398 - {
4399 - Metasync_Settings_Fields::instance()->open_external_links_callback();
4400 - }
4401 -
4402 - /**
4403 - * Get the settings option array and print one of its values
4404 - */
4405 - public function add_alt_image_tags_callback()
4406 - {
4407 - Metasync_Settings_Fields::instance()->add_alt_image_tags_callback();
4408 - }
4409 -
4410 - /**
4411 - * Get the settings option array and print one of its values
4412 - */
4413 - public function add_title_image_tags_callback()
4414 - {
4415 - Metasync_Settings_Fields::instance()->add_title_image_tags_callback();
4416 - }
4417 -
4418 - /**
4419 - * Get the settings option array and print one of its values
4420 - */
4421 - public function site_type_callback()
4422 - {
4423 - Metasync_Settings_Fields::instance()->site_type_callback();
4424 - }
4425 -
4426 - /**
4427 - * Get the settings option array and print one of its values
4428 - */
4429 - public function site_business_type_callback()
4430 - {
4431 - Metasync_Settings_Fields::instance()->site_business_type_callback();
4432 - }
4433 -
4434 - /**
4435 - * Get the settings option array and print one of its values
4436 - */
4437 - public function site_company_name_callback()
4438 - {
4439 - Metasync_Settings_Fields::instance()->site_company_name_callback();
4440 - }
4441 -
4442 - /**
4443 - * Get the settings option array and print one of its values
4444 - */
4445 - public function site_google_logo_callback()
4446 - {
4447 - Metasync_Settings_Fields::instance()->site_google_logo_callback();
4448 - }
4449 -
4450 - /**
4451 - * Get the settings option array and print one of its values
4452 - */
4453 - public function site_social_share_image_callback()
4454 - {
4455 - Metasync_Settings_Fields::instance()->site_social_share_image_callback();
4456 - }
4457 -
4458 - /**
4459 - * Get the settings option array and print one of its values
4460 - */
4461 5126 public function common_robot_meta_tags_callback()
4462 5127 {
4463 5128 Metasync_Settings_Fields::instance()->common_robot_meta_tags_callback();
4464 5129 }
@@ -4635,13 +5300,11 @@
4635 5300 $current_permalink_structure = get_option('permalink_structure');
4636 5301
4637 5302 # Get the plugin name using centralized method
4638 5303 $plugin_name = Metasync::get_effective_plugin_name();
4639 - $current_rewrite_rules = get_option('rewrite_rules');
5304 +
4640 5305 # Check if the current permalink structure is set to "Plain"
4641 - if (($current_permalink_structure == '/%post_id%/' || $current_permalink_structure == '') && $current_rewrite_rules == '') {
4642 -
4643 - # Show admin notice with plugin name included in the message
5306 + if ($current_permalink_structure === '/%post_id%/' || $current_permalink_structure === '') {
4644 5307 printf(
4645 5308 '<div class="notice notice-error is-dismissible">
4646 5309 <p>
4647 5310 <b>Warning from %s</b><br>
@@ -4651,9 +5314,8 @@
4651 5314 </div>',
4652 5315 esc_html($plugin_name)
4653 5316 );
4654 5317 }
4655 - flush_rewrite_rules();
4656 5318 }
4657 5319
4658 5320 /**
4659 5321 * Show a one-time admin notice when a page builder is detected but the
@@ -4659,8 +5321,14 @@
4659 5321 * Show a one-time admin notice when a page builder is detected but the
4660 5322 * "Default Page Builder" setting has never been explicitly saved.
4661 5323 */
4662 5324 public function display_page_builder_notice() {
5325 + // Only relevant on the plugin's own settings page — avoid repeating
5326 + // this notice across every admin screen.
5327 + if (!isset($_GET['page']) || wp_unslash($_GET['page']) !== self::$page_slug) {
5328 + return;
5329 + }
5330 +
4663 5331 $configured = Metasync::get_option('general')['default_page_builder'] ?? '';
4664 5332
4665 5333 // Setting already saved — nothing to warn about
4666 5334 if (!empty($configured)) {
@@ -4673,9 +5341,9 @@
4673 5341 return;
4674 5342 }
4675 5343
4676 5344 // Handle dismiss action
4677 - if (isset($_GET['metasync_dismiss_builder_notice']) && wp_verify_nonce($_GET['_wpnonce'] ?? '', 'metasync_dismiss_builder')) {
5345 + if (isset($_GET['metasync_dismiss_builder_notice'], $_GET['_wpnonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'metasync_dismiss_builder')) {
4678 5346 update_user_meta(get_current_user_id(), 'metasync_builder_notice_dismissed', '1');
4679 5347 return;
4680 5348 }
4681 5349
@@ -4770,9 +5438,10 @@
4770 5438 * Saves the CPU load threshold and returns statistics
4771 5439 */
4772 5440 public function ajax_save_performance_settings() {
4773 5441 # Check nonce for security and return early if invalid
4774 - if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce($_POST['meta_sync_nonce'], 'meta_sync_general_setting_nonce')) {
5442 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5443 + if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce(wp_unslash($_POST['meta_sync_nonce']), 'meta_sync_general_setting_nonce')) {
4775 5444 wp_send_json_error(array('message' => 'Invalid nonce'));
4776 5445 return;
4777 5446 }
4778 5447
@@ -4840,9 +5509,9 @@
4840 5509 if (!wp_next_scheduled('metasync_cleanup_transients')) {
4841 5510 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_cleanup_transients');
4842 5511
4843 5512 if (!$scheduled) {
4844 - error_log('MetaSync: Failed to schedule transient cleanup cron job');
5513 + error_log('MetaSync: Failed to schedule transient cleanup cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
4845 5514 }
4846 5515 }
4847 5516 }
4848 5517
@@ -4853,9 +5522,9 @@
4853 5522 {
4854 5523 $timestamp = wp_next_scheduled('metasync_cleanup_transients');
4855 5524 if ($timestamp) {
4856 5525 wp_unschedule_event($timestamp, 'metasync_cleanup_transients');
4857 - error_log('MetaSync: Transient cleanup cron job unscheduled');
5526 + error_log('MetaSync: Transient cleanup cron job unscheduled'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- unreachable in production
4858 5527 }
4859 5528 }
4860 5529
4861 5530 /**
@@ -4877,12 +5546,10 @@
4877 5546 {
4878 5547 if (!wp_next_scheduled('metasync_hidden_post_check')) {
4879 5548 $scheduled = wp_schedule_event(time(), 'metasync_weekly', 'metasync_hidden_post_check');
4880 5549
4881 - if ($scheduled) {
4882 - error_log('MetaSync: Hidden post manager cron job scheduled successfully (runs every 7 days)');
4883 - } else {
4884 - error_log('MetaSync: Failed to schedule hidden post manager cron job');
5550 + if (!$scheduled) {
5551 + error_log('MetaSync: Failed to schedule hidden post manager cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
4885 5552 }
4886 5553 }
4887 5554 }
4888 5555
@@ -4893,12 +5560,10 @@
4893 5560 public function maybe_schedule_otto_recheck_404_cron()
4894 5561 {
4895 5562 if (!wp_next_scheduled('metasync_otto_recheck_404_exclusions')) {
4896 5563 $scheduled = wp_schedule_event(time(), 'metasync_daily_cleanup', 'metasync_otto_recheck_404_exclusions');
4897 - if ($scheduled) {
4898 - error_log('MetaSync: OTTO 404 recheck cron job scheduled successfully (runs daily)');
4899 - } else {
4900 - error_log('MetaSync: Failed to schedule OTTO 404 recheck cron job');
5564 + if (!$scheduled) {
5565 + error_log('MetaSync: Failed to schedule OTTO 404 recheck cron job'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
4901 5566 }
4902 5567 }
4903 5568 }
4904 5569
@@ -4971,13 +5636,13 @@
4971 5636
4972 5637 try {
4973 5638 // 1. Post revisions
4974 5639 if (!empty($settings['clean_post_revisions'])) {
4975 - $stats['post_revisions'] = (int) $wpdb->query(
5640 + $stats['post_revisions'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
4976 5641 "DELETE FROM {$wpdb->posts} WHERE post_type = 'revision'"
4977 5642 );
4978 5643 // Remove postmeta left behind by deleted revisions
4979 - $wpdb->query(
5644 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
4980 5645 "DELETE pm FROM {$wpdb->postmeta} pm
4981 5646 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
4982 5647 WHERE p.ID IS NULL"
4983 5648 );
@@ -4985,20 +5650,19 @@
4985 5650
4986 5651 // 2. Trashed posts + their postmeta
4987 5652 if (!empty($settings['clean_trashed_posts'])) {
4988 5653 // Collect IDs first to cleanly remove postmeta
4989 - $trashed_ids = $wpdb->get_col(
5654 + $trashed_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
4990 5655 "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'trash'"
4991 5656 );
4992 5657 if (!empty($trashed_ids)) {
4993 - $placeholders = implode(',', array_fill(0, count($trashed_ids), '%d'));
4994 - $wpdb->query(
5658 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
4995 5659 $wpdb->prepare(
4996 - "DELETE FROM {$wpdb->postmeta} WHERE post_id IN ($placeholders)",
5660 + 'DELETE FROM ' . $wpdb->postmeta . ' WHERE post_id IN (' . implode(',', array_fill(0, count($trashed_ids), '%d')) . ')',
4997 5661 $trashed_ids
4998 5662 )
4999 5663 );
5000 - $stats['trashed_posts'] = (int) $wpdb->query(
5664 + $stats['trashed_posts'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5001 5665 "DELETE FROM {$wpdb->posts} WHERE post_status = 'trash'"
5002 5666 );
5003 5667 } else {
5004 5668 $stats['trashed_posts'] = 0;
@@ -5006,9 +5670,9 @@
5006 5670 }
5007 5671
5008 5672 // 3. Trashed comments
5009 5673 if (!empty($settings['clean_trashed_comments'])) {
5010 - $stats['trashed_comments'] = (int) $wpdb->query(
5674 + $stats['trashed_comments'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5011 5675 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'trash'"
5012 5676 );
5013 5677 }
5014 5678
@@ -5013,9 +5677,9 @@
5013 5677 }
5014 5678
5015 5679 // 4. Spam comments
5016 5680 if (!empty($settings['clean_spam_comments'])) {
5017 - $stats['spam_comments'] = (int) $wpdb->query(
5681 + $stats['spam_comments'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5018 5682 "DELETE FROM {$wpdb->comments} WHERE comment_approved = 'spam'"
5019 5683 );
5020 5684 }
5021 5685
@@ -5021,15 +5685,15 @@
5021 5685
5022 5686 // 5. Expired transients — direct SQL, no cache flush
5023 5687 if (!empty($settings['clean_expired_transients'])) {
5024 5688 // Delete timeout rows that have already expired
5025 - $wpdb->query(
5689 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5026 5690 "DELETE FROM {$wpdb->options}
5027 5691 WHERE option_name LIKE '\_transient\_timeout\_%'
5028 5692 AND option_value + 0 < UNIX_TIMESTAMP()"
5029 5693 );
5030 5694 // Delete value rows whose timeout row no longer exists
5031 - $stats['expired_transients'] = (int) $wpdb->query(
5695 + $stats['expired_transients'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5032 5696 "DELETE o FROM {$wpdb->options} o
5033 5697 LEFT JOIN {$wpdb->options} t
5034 5698 ON t.option_name = CONCAT('_transient_timeout_', SUBSTRING(o.option_name, 12))
5035 5699 WHERE o.option_name LIKE '\_transient\_%'
@@ -5039,9 +5703,9 @@
5039 5703 }
5040 5704
5041 5705 // 6. Orphaned postmeta (post_id references a post that no longer exists)
5042 5706 if (!empty($settings['clean_orphaned_postmeta'])) {
5043 - $stats['orphaned_postmeta'] = (int) $wpdb->query(
5707 + $stats['orphaned_postmeta'] = (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk site-maintenance operations (revisions/trash/spam/transients/orphans) — no bulk WordPress API exists
5044 5708 "DELETE pm FROM {$wpdb->postmeta} pm
5045 5709 LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
5046 5710 WHERE p.ID IS NULL"
5047 5711 );
@@ -5053,12 +5717,10 @@
5053 5717 $settings['last_run_at'] = time();
5054 5718 $settings['last_run_stats'] = $stats;
5055 5719 update_option('metasync_db_cleanup_settings', $settings);
5056 5720
5057 - error_log('MetaSync: DB cleanup completed — ' . json_encode($stats));
5058 -
5059 5721 } catch (Exception $e) {
5060 - error_log('MetaSync: DB cleanup failed — ' . $e->getMessage());
5722 + error_log('MetaSync: DB cleanup failed — ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
5061 5723 }
5062 5724 }
5063 5725
5064 5726 /**
@@ -5275,10 +5937,11 @@
5275 5937 /**
5276 5938 * AJAX: Save DB cleanup settings and reschedule cron accordingly.
5277 5939 */
5278 5940 public function ajax_save_db_cleanup_settings() {
5941 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5279 5942 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5280 - !wp_verify_nonce($_POST['db_cleanup_settings_nonce'], 'metasync_db_cleanup_settings_nonce')) {
5943 + !wp_verify_nonce(wp_unslash($_POST['db_cleanup_settings_nonce']), 'metasync_db_cleanup_settings_nonce')) {
5281 5944 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
5282 5945 return;
5283 5946 }
5284 5947
@@ -5335,10 +5998,11 @@
5335 5998 * AJAX: Manually trigger the DB cleanup and return stats for the UI.
5336 5999 * Persists current form state first so unsaved checkbox changes are respected.
5337 6000 */
5338 6001 public function ajax_run_db_cleanup() {
6002 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce is validated immediately by wp_verify_nonce().
5339 6003 if (!isset($_POST['db_cleanup_settings_nonce']) ||
5340 - !wp_verify_nonce($_POST['db_cleanup_settings_nonce'], 'metasync_db_cleanup_settings_nonce')) {
6004 + !wp_verify_nonce(wp_unslash($_POST['db_cleanup_settings_nonce']), 'metasync_db_cleanup_settings_nonce')) {
5341 6005 wp_send_json_error(array('message' => 'Invalid security token.'));
5342 6006 return;
5343 6007 }
5344 6008
@@ -5414,35 +6078,8 @@
5414 6078 ));
5415 6079 }
5416 6080
5417 6081 /**
5418 - * Control plugin auto-updates based on user setting
5419 - *
5420 - * @param bool $update Whether to update
5421 - * @param object $item Update offer
5422 - * @return bool Whether to allow auto-update
5423 - */
5424 - public function control_plugin_auto_updates($update, $item)
5425 - {
5426 - # Check if the item object has the slug property
5427 - if (!isset($item->slug)) {
5428 - return $update;
5429 - }
5430 -
5431 - // Check if this is our plugin
5432 - if ($item->slug === 'metasync') {
5433 - $general_settings = Metasync::get_option('general') ?? [];
5434 - $enable_auto_updates = $general_settings['enable_auto_updates'] ?? false;
5435 -
5436 - // Return the user's preference (true = allow auto-updates, false = prevent)
5437 - return $enable_auto_updates === 'true' || $enable_auto_updates === true;
5438 - }
5439 -
5440 - // For other plugins, don't interfere with their auto-update settings
5441 - return $update;
5442 - }
5443 -
5444 - /**
5445 6082 * AJAX handler to add excluded URL for OTTO
5446 6083 */
5447 6084 public function ajax_otto_add_excluded_url()
5448 6085 {
@@ -5588,8 +6225,9 @@
5588 6225 // If page has raw HTML or custom CSS from conversion, show badge
5589 6226 if ($has_raw_html || !empty($has_custom_css)) {
5590 6227 $label = $this->get_html_source_label();
5591 6228 $tooltip = sprintf(
6229 + /* translators: %s: HTML source label. */
5592 6230 __('This page was created using %s HTML-to-Builder converter', 'metasync'),
5593 6231 $label
5594 6232 );
5595 6233
@@ -5628,8 +6266,16 @@
5628 6266 if (!$post || !in_array($post->post_type, array('post', 'page'))) {
5629 6267 return;
5630 6268 }
5631 6269
6270 + // Don't show the "HTML-to-Builder converter" banner on LPS-synced / custom-HTML
6271 + // pages: those are raw-HTML store-and-serve pages, NOT actually converted to a
6272 + // page builder, so the banner mislabels them. It still shows for pages genuinely
6273 + // produced by the converter.
6274 + if (function_exists('metasync_is_custom_or_lps_page') && metasync_is_custom_or_lps_page($post->ID)) {
6275 + return;
6276 + }
6277 +
5632 6278 $has_raw_html = get_post_meta($post->ID, '_metasync_raw_html_enabled', true);
5633 6279 $has_custom_css = get_post_meta($post->ID, '_metasync_custom_css', true);
5634 6280
5635 6281 if ($has_raw_html || !empty($has_custom_css)) {
@@ -5634,8 +6280,9 @@
5634 6280
5635 6281 if ($has_raw_html || !empty($has_custom_css)) {
5636 6282 $label = $this->get_html_source_label();
5637 6283 $message = sprintf(
6284 + /* translators: %s: HTML source label. */
5638 6285 __('This page was created using %s HTML-to-Builder converter. The design is preserved with custom CSS and inline styles.', 'metasync'),
5639 6286 '<strong>' . esc_html($label) . '</strong>'
5640 6287 );
5641 6288
@@ -5649,9 +6296,9 @@
5649 6296 <p class="metasync-editor-message">%s</p>
5650 6297 </div>
5651 6298 </div>',
5652 6299 esc_html($label),
5653 - $message
6300 + wp_kses($message, array('strong' => array()))
5654 6301 );
5655 6302 }
5656 6303 }
5657 6304
@@ -5674,9 +6321,9 @@
5674 6321 ?>
5675 6322 <fieldset class="inline-edit-col-left metasync-quick-edit-source">
5676 6323 <div class="inline-edit-col">
5677 6324 <label>
5678 - <span class="title"><?php _e('Source', 'metasync'); ?></span>
6325 + <span class="title"><?php esc_html_e('Source', 'metasync'); ?></span>
5679 6326 <span class="metasync-quick-edit-badge-container"></span>
5680 6327 </label>
5681 6328 </div>
5682 6329 </fieldset>
@@ -5688,8 +6335,9 @@
5688 6335 */
5689 6336 public function add_html_pages_dashboard_widget()
5690 6337 {
5691 6338 $label = $this->get_html_source_label();
6339 + /* translators: %s: HTML source label. */
5692 6340 $widget_title = sprintf(__('%s Pages', 'metasync'), $label);
5693 6341
5694 6342 wp_add_dashboard_widget(
5695 6343 'metasync_html_pages_widget',
@@ -5745,8 +6393,14 @@
5745 6393 * @return void Sends JSON response and exits
5746 6394 */
5747 6395 public function ajax_send_giapi()
5748 6396 {
6397 + check_ajax_referer('metasync_nonce', 'nonce');
6398 +
6399 + if (!Metasync::current_user_has_plugin_access()) {
6400 + wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
6401 + }
6402 +
5749 6403 $post_data = metasync_sanitize_input_array($_POST);
5750 6404 if (!isset($post_data['metasync_giapi_url'])) {
5751 6405 return;
5752 6406 }
@@ -5812,8 +6466,14 @@
5812 6466 * @return void Sends JSON response and exits
5813 6467 */
5814 6468 public function ajax_send_bing_indexnow()
5815 6469 {
6470 + check_ajax_referer('metasync_nonce', 'nonce');
6471 +
6472 + if (!Metasync::current_user_has_plugin_access()) {
6473 + wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
6474 + }
6475 +
5816 6476 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
5817 6477 $bing_instant_index = new Metasync_Bing_Instant_Index();
5818 6478 $bing_instant_index->send();
5819 6479 }
@@ -5818,34 +6478,8 @@
5818 6478 $bing_instant_index->send();
5819 6479 }
5820 6480
5821 6481 /**
5822 - * Save instant indexing settings (Google and Bing)
5823 - *
5824 - * @since 2.6.0
5825 - * @return void
5826 - */
5827 - public function save_instant_indexing_settings()
5828 - {
5829 - // Check if this is a settings submission
5830 - if (!isset($_POST['submit'])) {
5831 - return;
5832 - }
5833 -
5834 - // Save post types for Google Instant Indexing auto-submit
5835 - if (isset($_POST['metasync_post_types'])) {
5836 - $post_data = metasync_sanitize_input_array($_POST);
5837 - $post_types = is_array($post_data['metasync_post_types']) ? array_map('sanitize_title', $post_data['metasync_post_types']) : [];
5838 -
5839 - $settings = get_option('metasync_options_instant_indexing', ['post_types' => []]);
5840 - $settings['post_types'] = array_values($post_types);
5841 - update_option('metasync_options_instant_indexing', $settings);
5842 - }
5843 -
5844 - // Note: Bing Instant Indexing settings are saved via AJAX in save_bing_inline_settings_ajax()
5845 - }
5846 -
5847 - /**
5848 6482 * Save Bing instant indexing settings from inline form (Indexation Control page)
5849 6483 *
5850 6484 * @since 2.6.0
5851 6485 * @return bool True on success, false on failure
@@ -5915,12 +6549,24 @@
5915 6549 if (!empty($seo_controls['enable_googleinstantindex']) && $seo_controls['enable_googleinstantindex'] === 'true') {
5916 6550 $options = get_option('metasync_options_instant_indexing', ['post_types' => []]);
5917 6551 $post_types = isset($options['post_types']) && is_array($options['post_types']) ? $options['post_types'] : [];
5918 6552
5919 - if (in_array($post->post_type, $post_types) && function_exists('google_index_direct')) {
5920 - $service_info = google_index_direct()->get_service_account_info();
5921 - if (!isset($service_info['error'])) {
5922 - google_index_direct()->index_post($post_id, $post->post_type, 'update');
6553 + if (in_array($post->post_type, $post_types)) {
6554 + // save_post is the single owner of auto-submit and also fires
6555 + // on REST-created posts, where nothing else may have loaded
6556 + // the Google Index helpers yet — load them on demand instead
6557 + // of silently skipping.
6558 + if (!function_exists('google_index_direct')) {
6559 + $google_index_path = plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
6560 + if (file_exists($google_index_path)) {
6561 + require_once $google_index_path;
6562 + }
6563 + }
6564 + if (function_exists('google_index_direct')) {
6565 + $service_info = google_index_direct()->get_service_account_info();
6566 + if (!isset($service_info['error'])) {
6567 + google_index_direct()->index_post($post_id, $post->post_type, 'update');
6568 + }
5923 6569 }
5924 6570 }
5925 6571 }
5926 6572