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 | includes/class-metasync-settings-registration.php +626 -265 2.7.0 → 2.7.2 View file →
@@ -180,12 +180,13 @@
180 180 */
181 181 private function handle_plugin_access_roles_save()
182 182 {
183 183 if (isset($_POST['save_plugin_access_roles']) && $_POST['save_plugin_access_roles'] === 'yes') {
184 - if (isset($_POST['plugin_access_roles_nonce']) && wp_verify_nonce($_POST['plugin_access_roles_nonce'], 'metasync_plugin_access_roles_nonce')) {
184 + $access_roles_nonce = isset($_POST['plugin_access_roles_nonce']) ? sanitize_text_field(wp_unslash($_POST['plugin_access_roles_nonce'])) : '';
185 + if (wp_verify_nonce($access_roles_nonce, 'metasync_plugin_access_roles_nonce')) {
185 186
186 187 if (!Metasync::current_user_has_plugin_access()) {
187 - wp_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=advanced&access_roles_error=1&message=' . urlencode('Insufficient permissions')));
188 + wp_safe_redirect(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=advanced&access_roles_error=1&message=' . urlencode('Insufficient permissions')));
188 189 exit;
189 190 }
190 191
191 192 self::$defer_settings_recovery_lock_release = true;
@@ -203,9 +204,9 @@
203 204 $metasync_options['general'] = array();
204 205 }
205 206
206 207 if (isset($_POST['plugin_access_roles']) && is_array($_POST['plugin_access_roles'])) {
207 - $metasync_options['general']['plugin_access_roles'] = array_map('sanitize_text_field', $_POST['plugin_access_roles']);
208 + $metasync_options['general']['plugin_access_roles'] = array_map('sanitize_text_field', wp_unslash($_POST['plugin_access_roles']));
208 209 } else {
209 210 $metasync_options['general']['plugin_access_roles'] = array();
210 211 }
211 212
@@ -211,13 +212,13 @@
211 212
212 213 Metasync::set_option($metasync_options);
213 214
214 215 $redirect_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=advanced&access_roles_saved=1');
215 - wp_redirect($redirect_url);
216 + wp_safe_redirect($redirect_url);
216 217 exit;
217 218 } else {
218 219 $redirect_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=advanced&access_roles_error=1&message=' . urlencode('Invalid security token'));
219 - wp_redirect($redirect_url);
220 + wp_safe_redirect($redirect_url);
220 221 exit;
221 222 }
222 223 }
223 224 }
@@ -262,8 +263,14 @@
262 263 Metasync_Admin::option_key,
263 264 array(self::instance(), 'sanitize')
264 265 );
265 266
267 + // Analytics consent is deliberately NOT registered in the shared option
268 + // group: options.php writes null for every registered-but-absent field,
269 + // so saving any other page in the group would silently flip consent to
270 + // 'no'. It is persisted only by the General tab AJAX save path, which
271 + // handles both opt-in and opt-out explicitly.
272 +
266 273 // LLMs.txt settings are stored in a dedicated option so MCP tools and
267 274 // the admin UI share one source of truth.
268 275 register_setting(
269 276 Metasync_Admin::option_group,
@@ -342,9 +349,9 @@
342 349 $value = isset($settings['max_posts']) ? (int) $settings['max_posts'] : 50;
343 350 echo '<div class="llms-txt-dependent">';
344 351 printf(
345 352 '<input type="number" id="llms_txt_max_posts" name="metasync_llms_txt_settings[max_posts]" min="1" max="500" value="%d" />',
346 - $value
353 + absint($value)
347 354 );
348 355 echo '</div>';
349 356 },
350 357 $page_slug . '_general',
@@ -415,9 +422,9 @@
415 422 $value = isset($settings['max_posts_full']) ? (int) $settings['max_posts_full'] : 25;
416 423 echo '<div class="llms-full-dependent">';
417 424 printf(
418 425 '<input type="number" id="llms_txt_max_posts_full" name="metasync_llms_txt_settings[max_posts_full]" min="1" max="500" value="%d" />',
419 - $value
426 + absint($value)
420 427 );
421 428 echo '<p class="description">' . esc_html__('Maximum posts for /llms-full.txt (lower than summary to protect server performance).', 'metasync') . '</p>';
422 429 echo '</div>';
423 430 },
@@ -453,12 +460,12 @@
453 460 'Disallow Empty Archives',
454 461 function() use ($option_key) {
455 462 $noindex_empty_archives = Metasync::get_option('seo_controls')['noindex_empty_archives'] ?? 'false';
456 463 printf(
457 - '<input type="checkbox" id="noindex_empty_archives" name="' . $option_key . '[seo_controls][noindex_empty_archives]" value="true" %s />',
464 + '<input type="checkbox" id="noindex_empty_archives" name="' . esc_attr($option_key) . '[seo_controls][noindex_empty_archives]" value="true" %s />',
458 465 isset($noindex_empty_archives) && $noindex_empty_archives == 'true' ? 'checked' : ''
459 466 );
460 - printf('<span class="description"><strong>Empty Archives:</strong> When checked, automatically adds noindex to category, tag, author, and format archive pages that have no posts. Once posts are added to these archives, they will automatically be allowed for indexing. This prevents thin content pages from being indexed.</span>');
467 + echo '<span class="description"><strong>Empty Archives:</strong> When checked, automatically adds noindex to category, tag, author, and format archive pages that have no posts. Once posts are added to these archives, they will automatically be allowed for indexing. This prevents thin content pages from being indexed.</span>';
461 468 },
462 469 $page_slug . '_seo-controls',
463 470 $SECTION_SEO_CONTROLS
464 471 );
@@ -468,12 +475,12 @@
468 475 'Disallow Date Archives',
469 476 function() use ($option_key) {
470 477 $index_date_archives = Metasync::get_option('seo_controls')['index_date_archives'] ?? 'false';
471 478 printf(
472 - '<input type="checkbox" id="index_date_archives" name="' . $option_key . '[seo_controls][index_date_archives]" value="true" %s />',
479 + '<input type="checkbox" id="index_date_archives" name="' . esc_attr($option_key) . '[seo_controls][index_date_archives]" value="true" %s />',
473 480 isset($index_date_archives) && $index_date_archives == 'true' ? 'checked' : ''
474 481 );
475 - printf('<span class="description"><strong>Date Archives:</strong> When checked, prevents search engines from indexing date-based archive pages (e.g., /2024/01/, /2024/01/15/). These pages often have thin content and can dilute your site\'s SEO value. Recommended for most sites.</span>');
482 + echo '<span class="description"><strong>Date Archives:</strong> When checked, prevents search engines from indexing date-based archive pages (e.g., /2024/01/, /2024/01/15/). These pages often have thin content and can dilute your site\'s SEO value. Recommended for most sites.</span>';
476 483 },
477 484 $page_slug . '_seo-controls',
478 485 $SECTION_SEO_CONTROLS
479 486 );
@@ -483,12 +490,12 @@
483 490 'Disallow Tag Archives',
484 491 function() use ($option_key) {
485 492 $index_tag_archives = Metasync::get_option('seo_controls')['index_tag_archives'] ?? 'false';
486 493 printf(
487 - '<input type="checkbox" id="index_tag_archives" name="' . $option_key . '[seo_controls][index_tag_archives]" value="true" %s />',
494 + '<input type="checkbox" id="index_tag_archives" name="' . esc_attr($option_key) . '[seo_controls][index_tag_archives]" value="true" %s />',
488 495 isset($index_tag_archives) && $index_tag_archives == 'true' ? 'checked' : ''
489 496 );
490 - printf('<span class="description"><strong>Tag Archives:</strong> When checked, prevents search engines from indexing tag archive pages (e.g., /tag/technology/). Useful if you have many low-quality tag pages or want to focus on category-based organization instead.</span>');
497 + echo '<span class="description"><strong>Tag Archives:</strong> When checked, prevents search engines from indexing tag archive pages (e.g., /tag/technology/). Useful if you have many low-quality tag pages or want to focus on category-based organization instead.</span>';
491 498 },
492 499 $page_slug . '_seo-controls',
493 500 $SECTION_SEO_CONTROLS
494 501 );
@@ -498,12 +505,12 @@
498 505 'Disallow Author Archives',
499 506 function() use ($option_key) {
500 507 $index_author_archives = Metasync::get_option('seo_controls')['index_author_archives'] ?? 'false';
501 508 printf(
502 - '<input type="checkbox" id="index_author_archives" name="' . $option_key . '[seo_controls][index_author_archives]" value="true" %s />',
509 + '<input type="checkbox" id="index_author_archives" name="' . esc_attr($option_key) . '[seo_controls][index_author_archives]" value="true" %s />',
503 510 isset($index_author_archives) && $index_author_archives == 'true' ? 'checked' : ''
504 511 );
505 - printf('<span class="description"><strong>Author Archives:</strong> When checked, prevents search engines from indexing author archive pages (e.g., /author/john-doe/). Recommended for single-author sites or when author pages don\'t provide unique value. Multi-author sites may want to keep this unchecked.</span>');
512 + echo '<span class="description"><strong>Author Archives:</strong> When checked, prevents search engines from indexing author archive pages (e.g., /author/john-doe/). Recommended for single-author sites or when author pages don\'t provide unique value. Multi-author sites may want to keep this unchecked.</span>';
506 513 },
507 514 $page_slug . '_seo-controls',
508 515 $SECTION_SEO_CONTROLS
509 516 );
@@ -513,12 +520,12 @@
513 520 'Disallow Format Archives',
514 521 function() use ($option_key) {
515 522 $index_format_archives = Metasync::get_option('seo_controls')['index_format_archives'] ?? 'false';
516 523 printf(
517 - '<input type="checkbox" id="index_format_archives" name="' . $option_key . '[seo_controls][index_format_archives]" value="true" %s />',
524 + '<input type="checkbox" id="index_format_archives" name="' . esc_attr($option_key) . '[seo_controls][index_format_archives]" value="true" %s />',
518 525 isset($index_format_archives) && $index_format_archives == 'true' ? 'checked' : ''
519 526 );
520 - printf('<span class="description"><strong>Format Archives:</strong> When checked, prevents search engines from indexing post format archive pages (e.g., /type/aside/, /type/gallery/). These are rarely useful for SEO and can create duplicate content issues. Recommended for most sites.</span>');
527 + echo '<span class="description"><strong>Format Archives:</strong> When checked, prevents search engines from indexing post format archive pages (e.g., /type/aside/, /type/gallery/). These are rarely useful for SEO and can create duplicate content issues. Recommended for most sites.</span>';
521 528 },
522 529 $page_slug . '_seo-controls',
523 530 $SECTION_SEO_CONTROLS
524 531 );
@@ -528,12 +535,12 @@
528 535 'Disallow Category Archives',
529 536 function() use ($option_key) {
530 537 $index_category_archives = Metasync::get_option('seo_controls')['index_category_archives'] ?? 'false';
531 538 printf(
532 - '<input type="checkbox" id="index_category_archives" name="' . $option_key . '[seo_controls][index_category_archives]" value="true" %s />',
539 + '<input type="checkbox" id="index_category_archives" name="' . esc_attr($option_key) . '[seo_controls][index_category_archives]" value="true" %s />',
533 540 isset($index_category_archives) && $index_category_archives == 'true' ? 'checked' : ''
534 541 );
535 - printf('<span class="description"><strong>Category Archives:</strong> When checked, prevents search engines from indexing category archive pages (e.g., /category/news/). This may be useful if your category pages have thin content or if you want to consolidate SEO value on main pages instead. Use with caution as category pages can be valuable for site organization.</span>');
542 + echo '<span class="description"><strong>Category Archives:</strong> When checked, prevents search engines from indexing category archive pages (e.g., /category/news/). This may be useful if your category pages have thin content or if you want to consolidate SEO value on main pages instead. Use with caution as category pages can be valuable for site organization.</span>';
536 543 },
537 544 $page_slug . '_seo-controls',
538 545 $SECTION_SEO_CONTROLS
539 546 );
@@ -543,9 +550,9 @@
543 550 'Add No-follow to External Links',
544 551 function() use ($option_key) {
545 552 $add_nofollow_to_external_links = Metasync::get_option('seo_controls')['add_nofollow_to_external_links'] ?? 'false';
546 553 printf(
547 - '<input type="checkbox" id="add_nofollow_to_external_links" name="' . $option_key . '[seo_controls][add_nofollow_to_external_links]" value="true" %s />',
554 + '<input type="checkbox" id="add_nofollow_to_external_links" name="' . esc_attr($option_key) . '[seo_controls][add_nofollow_to_external_links]" value="true" %s />',
548 555 isset($add_nofollow_to_external_links) && $add_nofollow_to_external_links == 'true' ? 'checked' : ''
549 556 );
550 557 printf('<span class="description"><strong>No-follow External Links:</strong> When checked, automatically adds <code>rel="nofollow"</code> attribute to all external links appearing in posts, pages, and other post types when rendered by %s. This tells search engines not to follow these links, which can help preserve your site\'s SEO value and prevent passing link juice to external sites.</span>', esc_html(Metasync::get_whitelabel_otto_name()));
551 558 },
@@ -571,12 +578,12 @@
571 578 'Override Other Plugins\' Robots Tags',
572 579 function() use ($option_key) {
573 580 $override_robots_tags = Metasync::get_option('seo_controls')['override_robots_tags'] ?? 'false';
574 581 printf(
575 - '<input type="checkbox" id="override_robots_tags" name="' . $option_key . '[seo_controls][override_robots_tags]" value="true" %s />',
582 + '<input type="checkbox" id="override_robots_tags" name="' . esc_attr($option_key) . '[seo_controls][override_robots_tags]" value="true" %s />',
576 583 isset($override_robots_tags) && $override_robots_tags == 'true' ? 'checked' : ''
577 584 );
578 - printf('<span class="description"><strong>Override Robots Tags:</strong> When checked, ' . Metasync_Settings_Registration::instance()->get_effective_menu_title() . ' will take precedence over robots meta tags from other SEO plugins (Yoast, Rank Math, All in One SEO, etc.). This removes their noindex tags when you want to allow indexing on archive pages. Only enable this if other plugins are conflicting with your indexation settings.</span>');
585 + echo '<span class="description"><strong>Override Robots Tags:</strong> When checked, ' . esc_html(Metasync_Settings_Registration::instance()->get_effective_menu_title()) . ' will take precedence over robots meta tags from other SEO plugins (Yoast, Rank Math, All in One SEO, etc.). This removes their noindex tags when you want to allow indexing on archive pages. Only enable this if other plugins are conflicting with your indexation settings.</span>';
579 586 },
580 587 $page_slug . '_seo-controls',
581 588 $SECTION_SEO_CONTROLS_ADVANCED
582 589 );
@@ -599,13 +606,13 @@
599 606 'Enable Google Instant Indexing',
600 607 function() use ($option_key) {
601 608 $enable_googleinstantindex = Metasync::get_option('seo_controls')['enable_googleinstantindex'] ?? 'false';
602 609 printf(
603 - '<input type="checkbox" id="enable_googleinstantindex" name="' . $option_key . '[seo_controls][enable_googleinstantindex]" value="true" %s />',
610 + '<input type="checkbox" id="enable_googleinstantindex" name="' . esc_attr($option_key) . '[seo_controls][enable_googleinstantindex]" value="true" %s />',
604 611 isset($enable_googleinstantindex) && $enable_googleinstantindex == 'true' ? 'checked' : ''
605 612 );
606 613 Metasync::render_tooltip_icon('enable_googleinstantindex', 'Turns on direct URL submission to Google. It only works after you add a service-account key below and verify your site in Search Console — set those up first.');
607 - printf('<span class="description" style="margin-left:8px;"><strong>Enable Instant Indexing:</strong> When checked, enables the Google Instant Indexing feature which allows you to submit URLs directly to Google for faster indexing. Configure it in the Google Instant Indexing section below.</span>');
614 + echo '<span class="description" style="margin-left:8px;"><strong>Enable Instant Indexing:</strong> When checked, enables the Google Instant Indexing feature which allows you to submit URLs directly to Google for faster indexing. Configure it in the Google Instant Indexing section below.</span>';
608 615 },
609 616 $page_slug . '_seo-controls',
610 617 $SECTION_SEO_CONTROLS_INSTANT_INDEX
611 618 );
@@ -646,13 +653,13 @@
646 653 $enable_binginstantindex = 'true';
647 654 }
648 655
649 656 printf(
650 - '<input type="checkbox" id="enable_binginstantindex" name="' . $option_key . '[seo_controls][enable_binginstantindex]" value="true" %s %s />',
657 + '<input type="checkbox" id="enable_binginstantindex" name="' . esc_attr($option_key) . '[seo_controls][enable_binginstantindex]" value="true" %s %s />',
651 658 isset($enable_binginstantindex) && $enable_binginstantindex == 'true' ? 'checked' : '',
652 659 $has_api_key ? 'data-auto-enabled="true"' : ''
653 660 );
654 - printf('<span class="description"><strong>Enable Bing Instant Indexing:</strong> Automatically enabled when you configure an API key below. When enabled, this activates the IndexNow protocol to instantly notify Bing, Yandex, and other search engines about URL changes.</span>');
661 + echo '<span class="description"><strong>Enable Bing Instant Indexing:</strong> Automatically enabled when you configure an API key below. When enabled, this activates the IndexNow protocol to instantly notify Bing, Yandex, and other search engines about URL changes.</span>';
655 662 },
656 663 $page_slug . '_seo-controls',
657 664 'seo_controls_bing_instant_index'
658 665 );
@@ -688,14 +695,30 @@
688 695 * Note: OTTO SSR is always enabled by default when connected
689 696 */
690 697
691 698 # field to accept the otto pixel
699 + # Read-only: the UUID is populated only by the trusted Search
700 + # Atlas / heartbeat flows. The input stays in the DOM (readonly, like
701 + # the apikey/linkgraph_token fields) so metasync-admin.js can keep
702 + # targeting it by name to reflect connect/disconnect state, and the
703 + # server rejects any submitted value regardless.
692 704 add_settings_field(
693 705 'otto_pixel_uuid',
694 706 $whitelabel_otto_name . ' Pixel UUID',
695 - function() use ($option_key) {
696 - $value = Metasync::get_option('general')['otto_pixel_uuid'] ?? '';
697 - printf('<input type="text" size="40" value = "'.esc_attr($value).'" name="' . $option_key . '[general][otto_pixel_uuid]"/>');
707 + function() use ($option_key, $whitelabel_otto_name) {
708 + $value = Metasync::get_option('general')['otto_pixel_uuid'] ?? '';
709 + printf(
710 + '<input type="text" id="otto_pixel_uuid" size="40" value="%s" name="%s" readonly="readonly" />',
711 + esc_attr($value),
712 + esc_attr($option_key . '[general][otto_pixel_uuid]')
713 + );
714 + printf(
715 + '<p class="description">%s</p>',
716 + esc_html(sprintf(
717 + 'Read-only. This UUID is assigned automatically by %s when you connect your account.',
718 + $whitelabel_otto_name
719 + ))
720 + );
698 721 },
699 722 $page_slug . '_general',
700 723 $SECTION_METASYNC
701 724 );
@@ -707,12 +730,12 @@
707 730 'Disable ' . $whitelabel_otto_name . ' for Logged in Users',
708 731 function() use ($whitelabel_otto_name, $option_key) {
709 732 $otto_disable_on_loggedin = Metasync::get_option('general')['otto_disable_on_loggedin'] ?? '';
710 733 printf(
711 - '<input type="checkbox" id="otto_disable_on_loggedin" name="' . $option_key . '[general][otto_disable_on_loggedin]" value="true" %s />',
734 + '<input type="checkbox" id="otto_disable_on_loggedin" name="' . esc_attr($option_key) . '[general][otto_disable_on_loggedin]" value="true" %s />',
712 735 isset($otto_disable_on_loggedin) && $otto_disable_on_loggedin == 'true' ? 'checked' : ''
713 736 );
714 - printf('<span class="description"> This disables '.$whitelabel_otto_name.' when logged in to allow editing original page contents</span>');
737 + echo '<span class="description"> This disables '.esc_html($whitelabel_otto_name).' when logged in to allow editing original page contents</span>';
715 738 },
716 739 $page_slug . '_general',
717 740 $SECTION_METASYNC
718 741 );
@@ -723,9 +746,9 @@
723 746 'Disable ' . $whitelabel_otto_name . ' Frontend Toolbar',
724 747 function() use ($whitelabel_otto_name, $option_key) {
725 748 $otto_disable_toolbar = Metasync::get_option('general')['otto_disable_preview_button'] ?? false;
726 749 printf(
727 - '<input type="checkbox" id="otto_disable_preview_button" name="' . $option_key . '[general][otto_disable_preview_button]" value="true" %s />',
750 + '<input type="checkbox" id="otto_disable_preview_button" name="' . esc_attr($option_key) . '[general][otto_disable_preview_button]" value="true" %s />',
728 751 filter_var($otto_disable_toolbar, FILTER_VALIDATE_BOOLEAN) ? 'checked' : ''
729 752 );
730 753 printf('<span class="description"> Hide the entire frontend toolbar (status indicator, preview button, and debug button) on the frontend. %s functionality will still work, but the toolbar controls will be hidden.</span>', esc_html($whitelabel_otto_name));
731 754 },
@@ -741,26 +764,27 @@
741 764 # OTTO deployment visible on a page that also holds a custom value.
742 765 #
743 766 # This only re-orders what RENDERS. Nothing is written to or deleted
744 767 # from the stored post meta, so unchecking it restores the custom value
745 - # untouched. Applies to the SEO title and meta description only.
768 + # untouched. Applies to the SEO title, meta description and focus
769 + # keyword.
746 770 add_settings_field(
747 771 'seo_priority',
748 - 'SEO Title &amp; Description Priority',
772 + 'SEO Meta Priority',
749 773 function() use ($whitelabel_otto_name, $option_key) {
750 774 $seo_priority = Metasync::get_option('general')['seo_priority'] ?? '';
751 775 $checked = $seo_priority === Metasync_Seo_Precedence::PRIORITY_OTTO ? 'checked' : '';
752 - $name = esc_attr($option_key) . '[general][seo_priority]';
776 + $name = $option_key . '[general][seo_priority]';
753 777
754 778 echo '<div class="metasync-seo-priority-control">';
755 779 printf(
756 780 '<label for="seo_priority"><input type="checkbox" id="seo_priority" name="%s" value="otto" %s /> <strong>Prioritize approved %s values</strong></label>',
757 - $name,
758 - $checked,
781 + esc_attr($name),
782 + esc_attr($checked),
759 783 esc_html($whitelabel_otto_name)
760 784 );
761 785 printf(
762 - '<p class="description" id="seo_priority_description"><strong>Off:</strong> Your custom title and description appear first; %1$s fills in only where you have no value.<br><strong>On:</strong> Approved %1$s values appear first; your custom values remain the fallback.<br><span class="metasync-seo-priority-note">Your saved values are never deleted or overwritten.</span></p>',
786 + '<p class="description" id="seo_priority_description"><strong>Off:</strong> Your custom SEO title, description and focus keyword — the values you edit on the post/page edit screen — appear first; %1$s fills in only where you have no value.<br><strong>On:</strong> Approved %1$s values appear first; your custom values remain the fallback.<br><span class="metasync-seo-priority-note">Your saved values are never deleted or overwritten.</span></p>',
763 787 esc_html($whitelabel_otto_name)
764 788 );
765 789 echo '</div>';
766 790 },
@@ -767,71 +791,87 @@
767 791 $page_slug . '_general',
768 792 $SECTION_METASYNC
769 793 );
770 794
771 - # WP Rocket compatibility mode setting
772 - add_settings_field(
773 - 'otto_wp_rocket_compat',
774 - 'WP Rocket Compatibility',
775 - function() use ($whitelabel_otto_name, $option_key) {
776 - $value = Metasync::get_option('general')['otto_wp_rocket_compat'] ?? 'auto';
777 - $wp_rocket_active = class_exists('WP_Rocket');
795 + # ───────────────────────────────────────────────────────────────
796 + # Plugin-compatibility toggles live on the dedicated Compatibility
797 + # page (admin.php?page=<slug>-compatibility), not the General tab.
798 + # Each toggle is registered only while its related plugin is active,
799 + # so the page never offers a control for a plugin that is not
800 + # present. The stored value survives while the field is hidden —
801 + # the save loop is isset-guarded and keeps absent keys.
802 + # ───────────────────────────────────────────────────────────────
803 + $compat_page_slug = $page_slug . '_compatibility';
778 804
779 - echo '<select id="otto_wp_rocket_compat" name="' . $option_key . '[general][otto_wp_rocket_compat]">';
780 - echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
781 - echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>';
782 - echo '<option value="http"' . selected($value, 'http', false) . '>HTTP Mode (Safer)</option>';
783 - echo '<option value="disable_otto"' . selected($value, 'disable_otto', false) . '>Disable ' . esc_html($whitelabel_otto_name) . ' when WP Rocket is active</option>';
784 - echo '</select>';
805 + add_settings_section(
806 + 'metasync_compat_toggles',
807 + '',
808 + function() {},
809 + $compat_page_slug
810 + );
785 811
786 - if ($wp_rocket_active) {
812 + # WP Rocket compatibility toggle.
813 + # Only registered when WP Rocket is actually present.
814 + # WP Rocket defines WP_ROCKET_VERSION; a WP_Rocket class only exists in
815 + # some builds, so check both.
816 + if (defined('WP_ROCKET_VERSION') || class_exists('WP_Rocket')) {
817 + add_settings_field(
818 + 'otto_wp_rocket_compat',
819 + 'WP Rocket Compatibility',
820 + function() use ($whitelabel_otto_name, $option_key) {
821 + $value = Metasync::get_option('general')['otto_wp_rocket_compat'] ?? 'auto';
822 +
823 + echo '<select id="otto_wp_rocket_compat" name="' . esc_attr($option_key) . '[general][otto_wp_rocket_compat]">';
824 + echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
825 + echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>';
826 + echo '<option value="http"' . selected($value, 'http', false) . '>HTTP Mode (Safer)</option>';
827 + echo '<option value="disable_otto"' . selected($value, 'disable_otto', false) . '>Disable ' . esc_html($whitelabel_otto_name) . ' when WP Rocket is active</option>';
828 + echo '</select>';
829 +
787 830 echo '<p class="description" style="color: #0073aa; margin-top: 8px;">✓ WP Rocket detected - compatibility mode active</p>';
788 - }
789 831
790 - echo '<p class="description" style="margin-top: 8px;">';
791 - echo '<strong>Auto (Recommended):</strong> Allows both ' . esc_html($whitelabel_otto_name) . ' and WP Rocket to work together. Does not set DONOTCACHEPAGE unless required (Brizy pages, SG Optimizer conflicts).<br>';
792 - echo '<strong>Buffer Mode:</strong> Forces output buffering method. Fastest but may conflict with some configurations.<br>';
793 - echo '<strong>HTTP Mode:</strong> Forces internal HTTP fetch method. Slower but more compatible.<br>';
794 - echo '<strong>Disable ' . esc_html($whitelabel_otto_name) . ':</strong> Completely disables ' . esc_html($whitelabel_otto_name) . ' when WP Rocket is detected. Use if issues persist.';
795 - echo '</p>';
796 - },
797 - $page_slug . '_general',
798 - $SECTION_METASYNC
799 - );
832 + echo '<p class="description" style="margin-top: 8px;">';
833 + echo '<strong>Auto (Recommended):</strong> Allows both ' . esc_html($whitelabel_otto_name) . ' and WP Rocket to work together. Does not set DONOTCACHEPAGE unless required (Brizy pages, SG Optimizer conflicts).<br>';
834 + echo '<strong>Buffer Mode:</strong> Forces output buffering method. Fastest but may conflict with some configurations.<br>';
835 + echo '<strong>HTTP Mode:</strong> Forces internal HTTP fetch method. Slower but more compatible.<br>';
836 + echo '<strong>Disable ' . esc_html($whitelabel_otto_name) . ':</strong> Completely disables ' . esc_html($whitelabel_otto_name) . ' when WP Rocket is detected. Use if issues persist.';
837 + echo '</p>';
838 + },
839 + $compat_page_slug,
840 + 'metasync_compat_toggles'
841 + );
842 + }
800 843
801 - # SiteGround Optimizer compatibility mode setting
802 - add_settings_field(
803 - 'otto_sg_optimizer_compat',
804 - 'SiteGround Optimizer Compatibility',
805 - function() use ($option_key) {
806 - $value = Metasync::get_option('general')['otto_sg_optimizer_compat'] ?? 'auto';
807 - $sg_optimizer_active = defined('SiteGround_Optimizer\VERSION') || class_exists('SiteGround_Optimizer\Parser\Parser');
844 + # SiteGround Optimizer compatibility toggle.
845 + # Only registered when SiteGround Optimizer is actually present.
846 + if (defined('SiteGround_Optimizer\VERSION') || class_exists('SiteGround_Optimizer\Parser\Parser')) {
847 + add_settings_field(
848 + 'otto_sg_optimizer_compat',
849 + 'SiteGround Optimizer Compatibility',
850 + function() use ($option_key) {
851 + $value = Metasync::get_option('general')['otto_sg_optimizer_compat'] ?? 'auto';
808 852
809 - echo '<select id="otto_sg_optimizer_compat" name="' . $option_key . '[general][otto_sg_optimizer_compat]">';
810 - echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
811 - echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>';
812 - echo '<option value="http"' . selected($value, 'http', false) . '>HTTP Mode (Safer)</option>';
813 - echo '</select>';
853 + echo '<select id="otto_sg_optimizer_compat" name="' . esc_attr($option_key) . '[general][otto_sg_optimizer_compat]">';
854 + echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
855 + echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>';
856 + echo '</select>';
814 857
815 - if ($sg_optimizer_active) {
816 858 echo '<p class="description" style="color: #0073aa; margin-top: 8px;">✓ SiteGround Optimizer detected - compatibility mode active</p>';
817 - }
818 859
819 - echo '<p class="description" style="margin-top: 8px;">';
820 - echo '<strong>Auto (Recommended):</strong> Uses the internal HTTP fetch method with SG Optimizer, protecting themes that defer inline CSS to the footer (e.g. Divi).<br>';
821 - echo '<strong>Buffer Mode (Faster):</strong> Renders in-process with no internal page fetch, roughly halving uncached render time. Best when pages can never be host-cached anyway (e.g. a plugin starts a PHP session on every request — though MetaSync already removes that automatically for BookingPress). May conflict with Divi-style themes.<br>';
822 - echo '<strong>HTTP Mode:</strong> Forces the internal HTTP fetch method. Slower but more compatible.';
823 - echo '</p>';
824 - },
825 - $page_slug . '_general',
826 - $SECTION_METASYNC
827 - );
860 + echo '<p class="description" style="margin-top: 8px;">';
861 + echo '<strong>Auto (Recommended):</strong> Uses the internal HTTP fetch method with SG Optimizer, protecting themes that defer inline CSS to the footer (e.g. Divi).<br>';
862 + echo '<strong>Buffer Mode (Faster):</strong> Renders in-process with no internal page fetch, roughly halving uncached render time. Best when pages can never be host-cached anyway (e.g. a plugin starts a PHP session on every request — though MetaSync already removes that automatically for BookingPress). May conflict with Divi-style themes.';
863 + echo '</p>';
864 + },
865 + $compat_page_slug,
866 + 'metasync_compat_toggles'
867 + );
868 + }
828 869
829 - # BookingPress front-end session compatibility setting.
870 + # BookingPress front-end session compatibility toggle.
830 871 # Only registered when BookingPress is actually present on the site:
831 872 # the shim no-ops without it, so the field would be noise for every
832 - # other install. The stored value survives while the field is hidden
833 - # (the save loop is isset-guarded and keeps absent keys).
873 + # other install.
834 874 if (class_exists('bookingpress_spam_protection')) {
835 875 add_settings_field(
836 876 'bookingpress_session_compat',
837 877 'BookingPress Session Compatibility',
@@ -837,9 +877,9 @@
837 877 'BookingPress Session Compatibility',
838 878 function() use ($option_key) {
839 879 $value = Metasync::get_option('general')['bookingpress_session_compat'] ?? 'auto';
840 880
841 - echo '<select id="bookingpress_session_compat" name="' . $option_key . '[general][bookingpress_session_compat]">';
881 + echo '<select id="bookingpress_session_compat" name="' . esc_attr($option_key) . '[general][bookingpress_session_compat]">';
842 882 echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
843 883 echo '<option value="off"' . selected($value, 'off', false) . '>Disabled</option>';
844 884 echo '</select>';
845 885
@@ -850,10 +890,10 @@
850 890 echo '<strong>Auto (Recommended):</strong> Removes the unnecessary session start on public pages so page caching can work.<br>';
851 891 echo '<strong>Disabled:</strong> Leaves BookingPress exactly as installed (no page caching while it stays active).';
852 892 echo '</p>';
853 893 },
854 - $page_slug . '_general',
855 - $SECTION_METASYNC
894 + $compat_page_slug,
895 + 'metasync_compat_toggles'
856 896 );
857 897 }
858 898
859 899 /**
@@ -867,9 +907,9 @@
867 907 'Disable ' . $whitelabel_otto_name . ' for Bot Traffic',
868 908 function() use ($whitelabel_otto_name, $option_key) {
869 909 $otto_disable_for_bots = Metasync::get_option('general')['otto_disable_for_bots'] ?? false;
870 910 printf(
871 - '<input type="checkbox" id="otto_disable_for_bots" name="' . $option_key . '[general][otto_disable_for_bots]" value="true" %s />',
911 + '<input type="checkbox" id="otto_disable_for_bots" name="' . esc_attr($option_key) . '[general][otto_disable_for_bots]" value="true" %s />',
872 912 filter_var($otto_disable_for_bots, FILTER_VALIDATE_BOOLEAN) ? 'checked' : ''
873 913 );
874 914 printf('<span class="description"> Skip %s processing for detected bots (search engines, crawlers, SEO tools) to reduce unnecessary API calls. View bot statistics in the Bot Statistics page under the SEO dropdown.</span>', esc_html($whitelabel_otto_name));
875 915 },
@@ -883,9 +923,9 @@
883 923 'Bot Whitelist',
884 924 function() use ($whitelabel_otto_name, $option_key) {
885 925 $otto_bot_whitelist = Metasync::get_option('general')['otto_bot_whitelist'] ?? '';
886 926 printf(
887 - '<textarea id="otto_bot_whitelist" name="' . $option_key . '[general][otto_bot_whitelist]" rows="5" cols="50" style="width: 100%%; max-width: 500px; font-family: monospace;">%s</textarea>',
927 + '<textarea id="otto_bot_whitelist" name="' . esc_attr($option_key) . '[general][otto_bot_whitelist]" rows="5" cols="50" style="width: 100%%; max-width: 500px; font-family: monospace;">%s</textarea>',
888 928 esc_textarea($otto_bot_whitelist)
889 929 );
890 930 echo '<p class="description">';
891 931 echo 'Enter bot names or user-agent patterns (one per line) that should <strong>always</strong> be processed by ' . esc_html($whitelabel_otto_name) . ', even when "Disable for Bots" is enabled.<br>';
@@ -904,9 +944,9 @@
904 944 'Bot Blacklist',
905 945 function() use ($whitelabel_otto_name, $option_key) {
906 946 $otto_bot_blacklist = Metasync::get_option('general')['otto_bot_blacklist'] ?? '';
907 947 printf(
908 - '<textarea id="otto_bot_blacklist" name="' . $option_key . '[general][otto_bot_blacklist]" rows="5" cols="50" style="width: 100%%; max-width: 500px; font-family: monospace;">%s</textarea>',
948 + '<textarea id="otto_bot_blacklist" name="' . esc_attr($option_key) . '[general][otto_bot_blacklist]" rows="5" cols="50" style="width: 100%%; max-width: 500px; font-family: monospace;">%s</textarea>',
909 949 esc_textarea($otto_bot_blacklist)
910 950 );
911 951 echo '<p class="description">';
912 952 echo 'Enter bot names or user-agent patterns (one per line) that should <strong>always</strong> be blocked from ' . esc_html($whitelabel_otto_name) . ' processing, regardless of other settings.<br>';
@@ -928,9 +968,9 @@
928 968 printf(
929 969 '<a href="%s" class="button button-secondary"><span class="dashicons dashicons-chart-bar" style="margin-top:3px;font-size:15px;width:15px;height:15px;"></span> View Bot Statistics</a>',
930 970 esc_url(admin_url('admin.php?page=' . $page_slug . '-bot-statistics'))
931 971 );
932 - printf('<p class="description">View detailed bot detection statistics, breakdown by bot type, and unique bot entries with hit counts.</p>');
972 + echo '<p class="description">View detailed bot detection statistics, breakdown by bot type, and unique bot entries with hit counts.</p>';
933 973 },
934 974 $page_slug . '_general',
935 975 $SECTION_METASYNC
936 976 );
@@ -941,22 +981,20 @@
941 981 'periodic_clear_ottopage_cache',
942 982 'Clear Page Cache',
943 983 function() use ($option_key) {
944 984 $periodic_clear_ottopage_cache = Metasync::get_option('general')['periodic_clear_ottopage_cache'] ?? 'default';
945 - printf('<select style = "width : 250px" name="' . $option_key . '[general][periodic_clear_ottopage_cache]" id="heading_style">');
946 - printf('<option value="24" '. selected($periodic_clear_ottopage_cache, '24', false) . '>Clear Daily</option>');
947 - printf('<option value="36" '. selected($periodic_clear_ottopage_cache, '36', false) . '>Clear Every 2 days</option>');
948 - printf('<option value="40" '. selected($periodic_clear_ottopage_cache, '40', false) . '>Clear Weekly</option>');
949 - printf('<option value="0"'.selected($periodic_clear_ottopage_cache, '0', false).'>Clear Monthly (Default)</option>');
950 - printf('</select>');
985 + echo '<select style = "width : 250px" name="' . esc_attr($option_key) . '[general][periodic_clear_ottopage_cache]" id="heading_style">';
986 + echo '<option value="24" '. selected($periodic_clear_ottopage_cache, '24', false) . '>Clear Daily</option>';
987 + echo '<option value="36" '. selected($periodic_clear_ottopage_cache, '36', false) . '>Clear Every 2 days</option>';
988 + echo '<option value="40" '. selected($periodic_clear_ottopage_cache, '40', false) . '>Clear Weekly</option>';
989 + echo '<option value="0"'.selected($periodic_clear_ottopage_cache, '0', false).'>Clear Monthly (Default)</option>';
990 + echo '</select>';
951 991
952 992 # get last cleared timestamp
953 993 $timestamp = get_option('metasync_refresh_all_caches')['pages'] ?? false;
954 994
955 995 if($timestamp > 0){
956 - printf(
957 - '<p class="descriptionValue">last Cleared : '.date('y-m-d H:i:s', $timestamp).'</p>'
958 - );
996 + echo '<p class="descriptionValue">last Cleared : '.esc_html(gmdate('y-m-d H:i:s', $timestamp)).'</p>';
959 997 }
960 998
961 999 },
962 1000 $page_slug . '_otto_cache',
@@ -967,22 +1005,20 @@
967 1005 'periodic_clear_ottopost_cache',
968 1006 'Clear Post Cache',
969 1007 function() use ($option_key) {
970 1008 $periodic_clear_ottopost_cache = Metasync::get_option('general')['periodic_clear_ottopost_cache'] ?? 'default';
971 - printf('<select style = "width : 250px" name="' . $option_key . '[general][periodic_clear_ottopost_cache]" id="heading_style">');
972 - printf('<option value="24" '. selected($periodic_clear_ottopost_cache, '24', false) . '>Clear Daily</option>');
973 - printf('<option value="36" '. selected($periodic_clear_ottopost_cache, '36', false) . '>Clear Every 2 days</option>');
974 - printf('<option value="40" '. selected($periodic_clear_ottopost_cache, '40', false) . '>Clear Weekly</option>');
975 - printf('<option value="0"'.selected($periodic_clear_ottopost_cache, '0', false).'>Clear Monthly (Default)</option>');
976 - printf('</select>');
1009 + echo '<select style = "width : 250px" name="' . esc_attr($option_key) . '[general][periodic_clear_ottopost_cache]" id="heading_style">';
1010 + echo '<option value="24" '. selected($periodic_clear_ottopost_cache, '24', false) . '>Clear Daily</option>';
1011 + echo '<option value="36" '. selected($periodic_clear_ottopost_cache, '36', false) . '>Clear Every 2 days</option>';
1012 + echo '<option value="40" '. selected($periodic_clear_ottopost_cache, '40', false) . '>Clear Weekly</option>';
1013 + echo '<option value="0"'.selected($periodic_clear_ottopost_cache, '0', false).'>Clear Monthly (Default)</option>';
1014 + echo '</select>';
977 1015
978 1016 # get last cleared timestamp
979 1017 $timestamp = get_option('metasync_refresh_all_caches')['posts'] ?? false;
980 1018
981 1019 if($timestamp > 0){
982 - printf(
983 - '<p class="descriptionValue">last Cleared : '.date('y-m-d H:i:s', $timestamp).'</p>'
984 - );
1020 + echo '<p class="descriptionValue">last Cleared : '.esc_html(gmdate('y-m-d H:i:s', $timestamp)).'</p>';
985 1021 }
986 1022 },
987 1023 $page_slug . '_otto_cache',
988 1024 $SECTION_METASYNC
@@ -992,22 +1028,20 @@
992 1028 'periodic_clear_otto_cache',
993 1029 'Clear all cache',
994 1030 function() use ($option_key) {
995 1031 $periodic_clear_otto_cache = Metasync::get_option('general')['periodic_clear_otto_cache'] ?? 'default';
996 - printf('<select style = "width : 250px" name="' . $option_key . '[general][periodic_clear_otto_cache]" id="heading_style">');
997 - printf('<option value="24" '. selected($periodic_clear_otto_cache, '24', false) . '>Clear Daily</option>');
998 - printf('<option value="36" '. selected($periodic_clear_otto_cache, '36', false) . '>Clear Every 2 days</option>');
999 - printf('<option value="40" '. selected($periodic_clear_otto_cache, '40', false) . '>Clear Weekly</option>');
1000 - printf('<option value="0"'.selected($periodic_clear_otto_cache, '0', false).'>Clear Monthly (Default)</option>');
1001 - printf('</select>');
1032 + echo '<select style = "width : 250px" name="' . esc_attr($option_key) . '[general][periodic_clear_otto_cache]" id="heading_style">';
1033 + echo '<option value="24" '. selected($periodic_clear_otto_cache, '24', false) . '>Clear Daily</option>';
1034 + echo '<option value="36" '. selected($periodic_clear_otto_cache, '36', false) . '>Clear Every 2 days</option>';
1035 + echo '<option value="40" '. selected($periodic_clear_otto_cache, '40', false) . '>Clear Weekly</option>';
1036 + echo '<option value="0"'.selected($periodic_clear_otto_cache, '0', false).'>Clear Monthly (Default)</option>';
1037 + echo '</select>';
1002 1038
1003 1039 # get last cleared timestamp
1004 1040 $timestamp = get_option('metasync_refresh_all_caches')['general'] ?? false;
1005 1041
1006 1042 if($timestamp > 0){
1007 - printf(
1008 - '<p class="descriptionValue">last Cleared : '.date('y-m-d H:i:s', $timestamp).'</p>'
1009 - );
1043 + echo '<p class="descriptionValue">last Cleared : '.esc_html(gmdate('y-m-d H:i:s', $timestamp)).'</p>';
1010 1044 }
1011 1045
1012 1046 },
1013 1047 $page_slug . '_otto_cache',
@@ -1022,12 +1056,12 @@
1022 1056 'Disable Common Robots Meta Box',
1023 1057 function() use ($option_key) {
1024 1058 $disabled = Metasync::get_option('general')['disable_common_robots_metabox'] ?? false;
1025 1059 printf(
1026 - '<input type="checkbox" id="disable_common_robots_metabox" name="' . $option_key . '[general][disable_common_robots_metabox]" value="1" %s />',
1060 + '<input type="checkbox" id="disable_common_robots_metabox" name="' . esc_attr($option_key) . '[general][disable_common_robots_metabox]" value="1" %s />',
1027 1061 $disabled ? 'checked' : ''
1028 1062 );
1029 - printf('<span class="description"> Hide the Common Robots meta box and stop %1$s emitting index/follow robots directives on the front end. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', Metasync::get_effective_plugin_name('MetaSync'));
1063 + printf('<span class="description"> Hide the Common Robots meta box and stop %1$s emitting index/follow robots directives on the front end. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
1030 1064 },
1031 1065 $page_slug . '_general',
1032 1066 $SECTION_METASYNC
1033 1067 );
@@ -1037,12 +1071,12 @@
1037 1071 'Disable Advance Robots Meta Box',
1038 1072 function() use ($option_key) {
1039 1073 $disabled = Metasync::get_option('general')['disable_advance_robots_metabox'] ?? false;
1040 1074 printf(
1041 - '<input type="checkbox" id="disable_advance_robots_metabox" name="' . $option_key . '[general][disable_advance_robots_metabox]" value="1" %s />',
1075 + '<input type="checkbox" id="disable_advance_robots_metabox" name="' . esc_attr($option_key) . '[general][disable_advance_robots_metabox]" value="1" %s />',
1042 1076 $disabled ? 'checked' : ''
1043 1077 );
1044 - printf('<span class="description"> Hide the Advance Robots meta box and stop %1$s emitting max-snippet, max-image-preview and max-video-preview directives. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', Metasync::get_effective_plugin_name('MetaSync'));
1078 + printf('<span class="description"> Hide the Advance Robots meta box and stop %1$s emitting max-snippet, max-image-preview and max-video-preview directives. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
1045 1079 },
1046 1080 $page_slug . '_general',
1047 1081 $SECTION_METASYNC
1048 1082 );
@@ -1052,12 +1086,12 @@
1052 1086 'Disable Redirection Meta Box',
1053 1087 function() use ($option_key) {
1054 1088 $disabled = Metasync::get_option('general')['disable_redirection_metabox'] ?? false;
1055 1089 printf(
1056 - '<input type="checkbox" id="disable_redirection_metabox" name="' . $option_key . '[general][disable_redirection_metabox]" value="1" %s />',
1090 + '<input type="checkbox" id="disable_redirection_metabox" name="' . esc_attr($option_key) . '[general][disable_redirection_metabox]" value="1" %s />',
1057 1091 $disabled ? 'checked' : ''
1058 1092 );
1059 - printf('<span class="description"> Hide the Redirection meta box and stop redirects created by it from running. Rules added on the Redirections screen are unaffected. Saved redirects are kept.</span>');
1093 + echo '<span class="description"> Hide the Redirection meta box and stop redirects created by it from running. Rules added on the Redirections screen are unaffected. Saved redirects are kept.</span>';
1060 1094 },
1061 1095 $page_slug . '_general',
1062 1096 $SECTION_METASYNC
1063 1097 );
@@ -1067,12 +1101,12 @@
1067 1101 'Disable Canonical Meta Box',
1068 1102 function() use ($option_key) {
1069 1103 $disabled = Metasync::get_option('general')['disable_canonical_metabox'] ?? false;
1070 1104 printf(
1071 - '<input type="checkbox" id="disable_canonical_metabox" name="' . $option_key . '[general][disable_canonical_metabox]" value="1" %s />',
1105 + '<input type="checkbox" id="disable_canonical_metabox" name="' . esc_attr($option_key) . '[general][disable_canonical_metabox]" value="1" %s />',
1072 1106 $disabled ? 'checked' : ''
1073 1107 );
1074 - printf('<span class="description"> Hide the Canonical meta box and stop %1$s emitting or overriding the canonical URL. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', Metasync::get_effective_plugin_name('MetaSync'));
1108 + printf('<span class="description"> Hide the Canonical meta box and stop %1$s emitting or overriding the canonical URL. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
1075 1109 },
1076 1110 $page_slug . '_general',
1077 1111 $SECTION_METASYNC
1078 1112 );
@@ -1082,12 +1116,12 @@
1082 1116 'Disable Social Media & Open Graph Meta Box',
1083 1117 function() use ($option_key) {
1084 1118 $disabled = Metasync::get_option('general')['disable_social_opengraph_metabox'] ?? false;
1085 1119 printf(
1086 - '<input type="checkbox" id="disable_social_opengraph_metabox" name="' . $option_key . '[general][disable_social_opengraph_metabox]" value="1" %s />',
1120 + '<input type="checkbox" id="disable_social_opengraph_metabox" name="' . esc_attr($option_key) . '[general][disable_social_opengraph_metabox]" value="1" %s />',
1087 1121 $disabled ? 'checked' : ''
1088 1122 );
1089 - printf('<span class="description"> Hide the Social Media &amp; Open Graph meta box and stop %1$s emitting Open Graph and Twitter tags. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', Metasync::get_effective_plugin_name('MetaSync'));
1123 + printf('<span class="description"> Hide the Social Media &amp; Open Graph meta box and stop %1$s emitting Open Graph and Twitter tags. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
1090 1124 },
1091 1125 $page_slug . '_general',
1092 1126 $SECTION_METASYNC
1093 1127 );
@@ -1097,12 +1131,12 @@
1097 1131 'Disable Schema Markup Meta Box',
1098 1132 function() use ($option_key) {
1099 1133 $disabled = Metasync::get_option('general')['disable_schema_markup_metabox'] ?? false;
1100 1134 printf(
1101 - '<input type="checkbox" id="disable_schema_markup_metabox" name="' . $option_key . '[general][disable_schema_markup_metabox]" value="1" %s />',
1135 + '<input type="checkbox" id="disable_schema_markup_metabox" name="' . esc_attr($option_key) . '[general][disable_schema_markup_metabox]" value="1" %s />',
1102 1136 $disabled ? 'checked' : ''
1103 1137 );
1104 - printf('<span class="description"> Hide the Schema Markup meta box and stop %1$s emitting any JSON-LD, including site-wide Local SEO markup. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', Metasync::get_effective_plugin_name('MetaSync'));
1138 + printf('<span class="description"> Hide the Schema Markup meta box and stop %1$s emitting any JSON-LD, including site-wide Local SEO markup. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
1105 1139 },
1106 1140 $page_slug . '_general',
1107 1141 $SECTION_METASYNC
1108 1142 );
@@ -1112,12 +1146,12 @@
1112 1146 'Disable Language Alternates Meta Box',
1113 1147 function() use ($option_key) {
1114 1148 $disabled = Metasync::get_option('general')['disable_language_alternates_metabox'] ?? false;
1115 1149 printf(
1116 - '<input type="checkbox" id="disable_language_alternates_metabox" name="' . $option_key . '[general][disable_language_alternates_metabox]" value="1" %s />',
1150 + '<input type="checkbox" id="disable_language_alternates_metabox" name="' . esc_attr($option_key) . '[general][disable_language_alternates_metabox]" value="1" %s />',
1117 1151 $disabled ? 'checked' : ''
1118 1152 );
1119 - printf('<span class="description"> Hide the Language Alternates (hreflang) panel in the block editor and stop %1$s emitting any %2$slink rel=&quot;alternate&quot; hreflang%3$s tags on the front end, including the auto-detected WPML entries. While disabled, %1$s does not manage or override this data — another SEO plugin, or WPML itself, is free to handle it. Saved values are kept and restored if you re-enable this.</span>', Metasync::get_effective_plugin_name('MetaSync'), '&lt;', '&gt;');
1153 + printf('<span class="description"> Hide the Language Alternates (hreflang) panel in the block editor and stop %1$s emitting any %2$slink rel=&quot;alternate&quot; hreflang%3$s tags on the front end, including the auto-detected WPML entries. While disabled, %1$s does not manage or override this data — another SEO plugin, or WPML itself, is free to handle it. Saved values are kept and restored if you re-enable this.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')), '&lt;', '&gt;');
1120 1154 },
1121 1155 $page_slug . '_general',
1122 1156 $SECTION_METASYNC
1123 1157 );
@@ -1127,12 +1161,12 @@
1127 1161 'Disable SEO Meta Box',
1128 1162 function() use ($option_key) {
1129 1163 $disabled = Metasync::get_option('general')['disable_seo_metabox'] ?? false;
1130 1164 printf(
1131 - '<input type="checkbox" id="disable_seo_metabox" name="' . $option_key . '[general][disable_seo_metabox]" value="1" %s />',
1165 + '<input type="checkbox" id="disable_seo_metabox" name="' . esc_attr($option_key) . '[general][disable_seo_metabox]" value="1" %s />',
1132 1166 $disabled ? 'checked' : ''
1133 1167 );
1134 - printf('<span class="description"> Hide the SEO Title & Meta Description meta box on post/page edit screens</span>');
1168 + echo '<span class="description"> Hide the SEO Title & Meta Description meta box on post/page edit screens</span>';
1135 1169 },
1136 1170 $page_slug . '_general',
1137 1171 $SECTION_METASYNC
1138 1172 );
@@ -1142,9 +1176,9 @@
1142 1176 'Open External Links in New Tab/Window',
1143 1177 function() use ($option_key) {
1144 1178 $enabled = Metasync::get_option('general')['open_external_links'] ?? false;
1145 1179 printf(
1146 - '<input type="checkbox" id="open_external_links" name="' . $option_key . '[general][open_external_links]" value="1" %s />',
1180 + '<input type="checkbox" id="open_external_links" name="' . esc_attr($option_key) . '[general][open_external_links]" value="1" %s />',
1147 1181 $enabled ? 'checked' : ''
1148 1182 );
1149 1183 printf('<span class="description"> Automatically add <code>target="_blank"</code> attribute to external links appearing in your posts, pages, and other post types when rendered by %s. The attribute is applied when the url is displayed.</span>', esc_html(Metasync::get_whitelabel_otto_name()));
1150 1184 },
@@ -1160,11 +1194,11 @@
1160 1194 $current_permalink_structure = get_option('permalink_structure');
1161 1195 $current_rewrite_rules = get_option('rewrite_rules');
1162 1196 // Check if the current permalink structure is set to "Plain"
1163 1197 if (($current_permalink_structure == '/%post_id%/' || $current_permalink_structure == '') && $current_rewrite_rules == '') {
1164 - printf('<span class="description" style="color:#ff0000;opacity:1;">To ensure compatibility, Please Update your Permalink structure to any option other than "plain. For any Inquiries contact support <a href="' . get_admin_url() . 'options-permalink.php">Check Setting</a> </span>');
1198 + echo '<span class="description" style="color:#ff0000;opacity:1;">To ensure compatibility, Please Update your Permalink structure to any option other than "plain. For any Inquiries contact support <a href="' . esc_url(get_admin_url() . 'options-permalink.php') . '">Check Setting</a> </span>';
1165 1199 } else {
1166 - printf('<span class="description" style="color:#008000;opacity:1;">Permalink is Okay </span>');
1200 + echo '<span class="description" style="color:#008000;opacity:1;">Permalink is Okay </span>';
1167 1201 }
1168 1202 },
1169 1203 $page_slug . '_general',
1170 1204 $SECTION_METASYNC
@@ -1176,9 +1210,9 @@
1176 1210 'Hide Dashboard',
1177 1211 function() use ($option_key) {
1178 1212 $hide_dashboard = Metasync::get_option('general')['hide_dashboard_framework'] ?? '';
1179 1213 printf(
1180 - '<input type="checkbox" id="hide_dashboard_framework" name="' . $option_key . '[general][hide_dashboard_framework]" value="true" %s />',
1214 + '<input type="checkbox" id="hide_dashboard_framework" name="' . esc_attr($option_key) . '[general][hide_dashboard_framework]" value="true" %s />',
1181 1215 isset($hide_dashboard) && $hide_dashboard == 'true' ? 'checked' : ''
1182 1216 );
1183 1217 printf('<span class="description"> Hide the %s dashboard</span>', esc_html(Metasync_Settings_Registration::instance()->get_effective_menu_title()));
1184 1218 },
@@ -1194,9 +1228,9 @@
1194 1228 'Show ' . $this->get_effective_menu_title() . ' Status in Admin Bar',
1195 1229 function() use ($option_key) {
1196 1230 $show_admin_bar = Metasync::get_option('general')['show_admin_bar_status'] ?? true;
1197 1231 printf(
1198 - '<input type="checkbox" id="show_admin_bar_status" name="' . $option_key . '[general][show_admin_bar_status]" value="true" %s />',
1232 + '<input type="checkbox" id="show_admin_bar_status" name="' . esc_attr($option_key) . '[general][show_admin_bar_status]" value="true" %s />',
1199 1233 $show_admin_bar ? 'checked' : ''
1200 1234 );
1201 1235 printf('<span class="description">Show the %s status indicator in the WordPress admin bar.</span>', esc_html(Metasync::get_effective_plugin_name()));
1202 1236 },
@@ -1203,24 +1237,8 @@
1203 1237 $page_slug . '_general',
1204 1238 $SECTION_METASYNC
1205 1239 );
1206 1240
1207 - # Adding the "Enable Auto-updates" setting
1208 - add_settings_field(
1209 - 'enable_auto_updates',
1210 - 'Enable Automatic Updates',
1211 - function() use ($option_key) {
1212 - $enable_auto_updates = Metasync::get_option('general')['enable_auto_updates'] ?? false;
1213 - printf(
1214 - '<input type="checkbox" id="enable_auto_updates" name="' . $option_key . '[general][enable_auto_updates]" value="true" %s />',
1215 - $enable_auto_updates ? 'checked' : ''
1216 - );
1217 - printf('<span class="description">Allow WordPress to automatically update this plugin when new versions are available.</span>');
1218 - },
1219 - $page_slug . '_general',
1220 - $SECTION_METASYNC
1221 - );
1222 -
1223 1241 # Add User Role Sync Setting
1224 1242 add_settings_field(
1225 1243 'content_genius_sync_roles',
1226 1244 'Content Genius User Roles to Sync',
@@ -1322,9 +1340,9 @@
1322 1340 echo '<select id="default_page_builder" name="' . esc_attr($option_key) . '[general][default_page_builder]" style="min-width: 300px;">';
1323 1341
1324 1342 foreach ($builders as $key => $builder) {
1325 1343 $is_detected = $builder['detected'];
1326 - $label = esc_html($builder['label']);
1344 + $label = $builder['label'];
1327 1345 if ($is_detected && $key !== 'gutenberg') {
1328 1346 $label .= ' — Detected';
1329 1347 } elseif (!$is_detected) {
1330 1348 $label .= ' — Not Detected';
@@ -1336,9 +1354,9 @@
1336 1354 '<option value="%s" %s%s>%s</option>',
1337 1355 esc_attr($key),
1338 1356 selected(empty($current) ? 'gutenberg' : $current, $key, false),
1339 1357 $is_detected ? '' : ' disabled',
1340 - $label
1358 + esc_html($label)
1341 1359 );
1342 1360 }
1343 1361
1344 1362 echo '</select>';
@@ -1387,9 +1405,9 @@
1387 1405 printf(
1388 1406 '<a href="%s" class="button button-secondary"><span class="dashicons dashicons-download" style="margin-top:3px;font-size:15px;width:15px;height:15px;"></span> Import from SEO Plugins</a>',
1389 1407 esc_url(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-import-external'))
1390 1408 );
1391 - printf('<p class="description">Import settings and data from other SEO plugins (Yoast, Rank Math, AIOSEO, etc).</p>');
1409 + echo '<p class="description">Import settings and data from other SEO plugins (Yoast, Rank Math, AIOSEO, etc).</p>';
1392 1410 },
1393 1411 $page_slug . '_general',
1394 1412 $SECTION_METASYNC
1395 1413 );
@@ -1404,10 +1422,10 @@
1404 1422 'white_label_plugin_name',
1405 1423 'Plugin Name' . Metasync::get_tooltip_icon_html('wl_plugin_name_tooltip', 'The name shown for this plugin throughout the WordPress admin — menus, page titles, and system messages. Replace it with your own brand name (max 18 characters).'),
1406 1424 function() use ($option_key) {
1407 1425 $value = Metasync::get_option('general')['white_label_plugin_name'] ?? '';
1408 - printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_name]" value="' . esc_attr($value) . '" maxlength="18" />');
1409 - printf('<p class="description">This name will be used for general plugin branding (WordPress menus, page titles, and system messages). Maximum 18 characters.</p>');
1426 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_name]" value="' . esc_attr($value) . '" maxlength="18" />';
1427 + echo '<p class="description">This name will be used for general plugin branding (WordPress menus, page titles, and system messages). Maximum 18 characters.</p>';
1410 1428 },
1411 1429 $page_slug . '_branding',
1412 1430 $SECTION_METASYNC
1413 1431 );
@@ -1416,9 +1434,9 @@
1416 1434 'whitelabel_otto_name',
1417 1435 'OTTO Name' . Metasync::get_tooltip_icon_html('wl_otto_name_tooltip', 'The name used wherever OTTO is mentioned in the plugin (e.g. "Enable [Name] Server Side Rendering"). Rename it to match your own product branding.'),
1418 1436 function() use ($option_key) {
1419 1437 $value = Metasync::get_option('general')['whitelabel_otto_name'] ?? '';
1420 - printf('<input type="text" name="' . $option_key . '[general][whitelabel_otto_name]" value="' . esc_attr($value) . '" />');
1438 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][whitelabel_otto_name]" value="' . esc_attr($value) . '" />';
1421 1439 $example_name = !empty($value) ? $value : 'OTTO';
1422 1440 printf('<p class="description">This name will be used for OTTO feature references (e.g., "Enable %s Server Side Rendering").</p>', esc_html($example_name));
1423 1441 },
1424 1442 $page_slug . '_branding',
@@ -1430,9 +1448,9 @@
1430 1448 'Logo (Light Theme)' . Metasync::get_tooltip_icon_html('wl_logo_light_tooltip', 'Your logo shown in the admin dashboard when it\'s set to light mode. Leave blank to use the default logo.'),
1431 1449 function() use ($option_key) {
1432 1450 $whitelabel_settings = Metasync::get_whitelabel_settings();
1433 1451 $value = $whitelabel_settings['logo_light'] ?? '';
1434 - printf('<input type="url" name="' . $option_key . '[whitelabel][logo_light]" value="' . esc_attr($value) . '" size="60" />');
1452 + echo '<input type="url" name="' . esc_attr($option_key) . '[whitelabel][logo_light]" value="' . esc_attr($value) . '" size="60" />';
1435 1453 printf('<p class="description">Displayed when the admin UI is in light mode. Leave blank to use the default %s logo.</p>', esc_html(Metasync::get_effective_plugin_name()));
1436 1454 },
1437 1455 $page_slug . '_branding',
1438 1456 $SECTION_METASYNC
@@ -1443,9 +1461,9 @@
1443 1461 'Logo (Dark Theme)' . Metasync::get_tooltip_icon_html('wl_logo_dark_tooltip', 'Your logo shown in the admin dashboard when it\'s set to dark mode. Leave blank to use the default logo.'),
1444 1462 function() use ($option_key) {
1445 1463 $whitelabel_settings = Metasync::get_whitelabel_settings();
1446 1464 $value = $whitelabel_settings['logo_dark'] ?? '';
1447 - printf('<input type="url" name="' . $option_key . '[whitelabel][logo_dark]" value="' . esc_attr($value) . '" size="60" />');
1465 + echo '<input type="url" name="' . esc_attr($option_key) . '[whitelabel][logo_dark]" value="' . esc_attr($value) . '" size="60" />';
1448 1466 printf('<p class="description">Displayed when the admin UI is in dark mode. Leave blank to use the default %s logo.</p>', esc_html(Metasync::get_effective_plugin_name()));
1449 1467 },
1450 1468 $page_slug . '_branding',
1451 1469 $SECTION_METASYNC
@@ -1456,10 +1474,10 @@
1456 1474 'Dashboard URL' . Metasync::get_tooltip_icon_html('wl_dashboard_url_tooltip', 'The web address of your own branded dashboard, shown to clients instead of the Search Atlas dashboard link. This is cosmetic only — it doesn\'t change where your data is actually stored.'),
1457 1475 function() use ($option_key) {
1458 1476 $whitelabel_settings = Metasync::get_whitelabel_settings();
1459 1477 $value = $whitelabel_settings['domain'] ?? '';
1460 - printf('<input type="url" name="' . $option_key . '[whitelabel][domain]" value="' . esc_attr($value) . '" size="60" />');
1461 - printf('<p class="description">Enter your whitelabel dashboard URL (e.g., https://yourdashboard.com). Used for branding purposes.</p>');
1478 + echo '<input type="url" name="' . esc_attr($option_key) . '[whitelabel][domain]" value="' . esc_attr($value) . '" size="60" />';
1479 + echo '<p class="description">Enter your whitelabel dashboard URL (e.g., https://yourdashboard.com). Used for branding purposes.</p>';
1462 1480 },
1463 1481 $page_slug . '_branding',
1464 1482 $SECTION_METASYNC
1465 1483 );
@@ -1467,9 +1485,9 @@
1467 1485 'white_label_plugin_description',
1468 1486 'Plugin Description',
1469 1487 function() use ($option_key) {
1470 1488 $value = Metasync::get_option('general')['white_label_plugin_description'] ?? '';
1471 - printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_description]" value="' . esc_attr($value) . '" />');
1489 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_description]" value="' . esc_attr($value) . '" />';
1472 1490 },
1473 1491 $page_slug . '_branding',
1474 1492 $SECTION_METASYNC
1475 1493 );
@@ -1478,9 +1496,9 @@
1478 1496 'white_label_plugin_author',
1479 1497 'Author',
1480 1498 function() use ($option_key) {
1481 1499 $value = Metasync::get_option('general')['white_label_plugin_author'] ?? '';
1482 - printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_author]" value="' . esc_attr($value) . '" />');
1500 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_author]" value="' . esc_attr($value) . '" />';
1483 1501 },
1484 1502 $page_slug . '_branding',
1485 1503 $SECTION_METASYNC
1486 1504 );
@@ -1489,9 +1507,9 @@
1489 1507 'white_label_plugin_author_uri',
1490 1508 'Author URI',
1491 1509 function() use ($option_key) {
1492 1510 $value = Metasync::get_option('general')['white_label_plugin_author_uri'] ?? '';
1493 - # printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_author_uri]" value="' . esc_attr($value) . '" />');
1511 + # printf('<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_author_uri]" value="' . esc_attr($value) . '" />');
1494 1512 # Fixed printf usage
1495 1513 printf('<input type="text" name="%s" value="%s" />', esc_attr($option_key . '[general][white_label_plugin_author_uri]'), esc_attr($value) );
1496 1514
1497 1515 },
@@ -1502,9 +1520,9 @@
1502 1520 'white_label_plugin_uri',
1503 1521 'Plugin URI',
1504 1522 function() use ($option_key) {
1505 1523 $value = Metasync::get_option('general')['white_label_plugin_uri'] ?? ''; // New option for Plugin URI
1506 - printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_uri]" value="' . esc_attr($value) . '" />');
1524 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_uri]" value="' . esc_attr($value) . '" />';
1507 1525 },
1508 1526 $page_slug . '_branding',
1509 1527 $SECTION_METASYNC
1510 1528 );
@@ -1518,10 +1536,10 @@
1518 1536 'white_label_plugin_menu_slug',
1519 1537 'Menu Slug',
1520 1538 function() use ($option_key) {
1521 1539 $value = Metasync::get_option('general')['white_label_plugin_menu_slug'] ?? '';
1522 - printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_menu_slug]" value="' . esc_attr($value) . '" placeholder="hip-advanced-seo" />');
1523 - printf('<p class="description">Short identifier for the plugin\'s admin URL &mdash; lowercase letters, numbers, and dashes only (e.g. <code>hip-advanced-seo</code>). Don\'t paste a full web address.</p>');
1540 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_menu_slug]" value="' . esc_attr($value) . '" placeholder="hip-advanced-seo" />';
1541 + echo '<p class="description">Short identifier for the plugin\'s admin URL &mdash; lowercase letters, numbers, and dashes only (e.g. <code>hip-advanced-seo</code>). Don\'t paste a full web address.</p>';
1524 1542 },
1525 1543 $page_slug . '_branding',
1526 1544 $SECTION_METASYNC
1527 1545 );
@@ -1529,9 +1547,9 @@
1529 1547 'white_label_plugin_menu_icon',
1530 1548 'Menu Icon',
1531 1549 function() use ($option_key) {
1532 1550 $value = Metasync::get_option('general')['white_label_plugin_menu_icon'] ?? '';
1533 - echo '<input type="text" name="' . $option_key . '[general][white_label_plugin_menu_icon]" value="' . esc_attr($value) . '" />';
1551 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_menu_icon]" value="' . esc_attr($value) . '" />';
1534 1552 },
1535 1553 $page_slug . '_branding',
1536 1554 $SECTION_METASYNC
1537 1555 );
@@ -1546,22 +1564,22 @@
1546 1564
1547 1565 // Output radio button for Default Style.css active
1548 1566
1549 1567 printf(
1550 - '<input type="radio" id="enable_default" name="' . $option_key . '[general][enabled_plugin_css]" value="default" %s />',
1568 + '<input type="radio" id="enable_default" name="' . esc_attr($option_key) . '[general][enabled_plugin_css]" value="default" %s />',
1551 1569 ($enabled_plugin_css == 'default'||$enabled_plugin_css =='') ? 'checked' : ''
1552 1570 );
1553 - printf('<label for="enable_default">Default</label><br>');
1571 + echo '<label for="enable_default">Default</label><br>';
1554 1572
1555 1573
1556 1574 // Output radio button for Metasync Style
1557 1575 printf(
1558 - '<input type="radio" id="enable_metasync" name="' . $option_key . '[general][enabled_plugin_css]" value="metasync" %s />',
1576 + '<input type="radio" id="enable_metasync" name="' . esc_attr($option_key) . '[general][enabled_plugin_css]" value="metasync" %s />',
1559 1577 ($enabled_plugin_css == 'metasync') ? 'checked' : ''
1560 1578 );
1561 - printf('<label for="enable_metasync">Metasync Style</label>');
1579 + echo '<label for="enable_metasync">Metasync Style</label>';
1562 1580
1563 - printf('<p class="description"> Choose the default page Style Sheet: Default or MetaSync.</p>');
1581 + echo '<p class="description"> Choose the default page Style Sheet: Default or MetaSync.</p>';
1564 1582 },
1565 1583 $page_slug . '_branding',
1566 1584 $SECTION_METASYNC
1567 1585 );
@@ -1571,12 +1589,12 @@
1571 1589 'enabled_elementor_plugin_css',
1572 1590 'Choose Elementor Font Style',
1573 1591 function() use ($option_key) {
1574 1592 $enabled_elementor_plugin_css = Metasync::get_option('general')['enabled_elementor_plugin_css'] ?? 'default';
1575 - printf('<select name="' . $option_key . '[general][enabled_elementor_plugin_css]" id="heading_style">');
1576 - printf('<option value="default"'.selected($enabled_elementor_plugin_css, 'default', false).'>Default</option>');
1577 - printf('<option value="custom" '. selected($enabled_elementor_plugin_css, 'custom', false) . '>Custom</option>');
1578 - printf('</select>');
1593 + echo '<select name="' . esc_attr($option_key) . '[general][enabled_elementor_plugin_css]" id="heading_style">';
1594 + echo '<option value="default"'.selected($enabled_elementor_plugin_css, 'default', false).'>Default</option>';
1595 + echo '<option value="custom" '. selected($enabled_elementor_plugin_css, 'custom', false) . '>Custom</option>';
1596 + echo '</select>';
1579 1597 },
1580 1598 $page_slug . '_branding',
1581 1599 $SECTION_METASYNC
1582 1600 );
@@ -1584,9 +1602,9 @@
1584 1602 'enabled_elementor_plugin_css_color',
1585 1603 'Choose Elementor Font Color',
1586 1604 function() use ($option_key) {
1587 1605 $enabled_elementor_plugin_css_color = Metasync::get_option('general')['enabled_elementor_plugin_css_color'] ?? '#000000';
1588 - printf('<input type="color" id="elementor_default_color_metasync" name="' . $option_key . '[general][enabled_elementor_plugin_css_color]" value="'.$enabled_elementor_plugin_css_color.'">');
1606 + echo '<input type="color" id="elementor_default_color_metasync" name="' . esc_attr($option_key) . '[general][enabled_elementor_plugin_css_color]" value="'.esc_attr($enabled_elementor_plugin_css_color).'">';
1589 1607 },
1590 1608 $page_slug . '_branding',
1591 1609 $SECTION_METASYNC
1592 1610 );
@@ -1597,10 +1615,10 @@
1597 1615 function() use ($option_key) {
1598 1616 // Decrypted for display — this field only renders after the
1599 1617 // whitelabel password gate has been passed.
1600 1618 $value = Metasync::get_whitelabel_password();
1601 - printf('<input type="password" name="' . $option_key . '[whitelabel][settings_password]" value="' . esc_attr($value) . '" size="30" autocomplete="new-password" />');
1602 - printf('<p class="description">Set a custom password to protect the branding settings section.</p>');
1619 + echo '<input type="password" name="' . esc_attr($option_key) . '[whitelabel][settings_password]" value="' . esc_attr($value) . '" size="30" autocomplete="new-password" />';
1620 + echo '<p class="description">Set a custom password to protect the branding settings section.</p>';
1603 1621 },
1604 1622 $page_slug . '_branding',
1605 1623 $SECTION_METASYNC
1606 1624 );
@@ -1611,10 +1629,10 @@
1611 1629 function() use ($option_key) {
1612 1630 $whitelabel_settings = Metasync::get_whitelabel_settings();
1613 1631 $value = $whitelabel_settings['recovery_email'] ?? '';
1614 1632 $has_password = !empty($whitelabel_settings['settings_password']);
1615 - printf('<input type="email" name="' . $option_key . '[whitelabel][recovery_email]" value="' . esc_attr($value) . '" size="30" autocomplete="email" %s />', $has_password ? 'required' : '');
1616 - printf('<p class="description">Email address to receive password recovery. <strong>Required when password is set.</strong></p>');
1633 + printf('<input type="email" name="' . esc_attr($option_key) . '[whitelabel][recovery_email]" value="' . esc_attr($value) . '" size="30" autocomplete="email" %s />', $has_password ? 'required' : '');
1634 + echo '<p class="description">Email address to receive password recovery. <strong>Required when password is set.</strong></p>';
1617 1635 },
1618 1636 $page_slug . '_branding',
1619 1637 $SECTION_METASYNC
1620 1638 );
@@ -1644,11 +1662,11 @@
1644 1662 <td><input type="text" name="%s[whitelabel][quick_links][%d][label]" value="%s" placeholder="Link label" style="width:100%%" /></td>
1645 1663 <td><input type="url" name="%s[whitelabel][quick_links][%d][url]" value="%s" placeholder="https://…" style="width:100%%" /></td>
1646 1664 <td style="text-align:center"><input type="checkbox" name="%s[whitelabel][quick_links][%d][external]" value="1" %s /></td>
1647 1665 </tr>',
1648 - esc_attr($option_key), $i, $label,
1649 - esc_attr($option_key), $i, $url,
1650 - esc_attr($option_key), $i, checked($external, true, false)
1666 + esc_attr($option_key), esc_attr($i), esc_attr($label),
1667 + esc_attr($option_key), esc_attr($i), esc_attr($url),
1668 + esc_attr($option_key), esc_attr($i), checked($external, true, false)
1651 1669 );
1652 1670 }
1653 1671 echo '</tbody></table>';
1654 1672 },
@@ -1675,9 +1693,9 @@
1675 1693 'Organization Name',
1676 1694 function() use ($option_key) {
1677 1695 $schema = Metasync::get_option('schema') ?? [];
1678 1696 $value = $schema['org_name'] ?? '';
1679 - printf('<input type="text" name="' . $option_key . '[schema][org_name]" value="%s" size="40" placeholder="Your organization name" />', esc_attr($value));
1697 + printf('<input type="text" name="' . esc_attr($option_key) . '[schema][org_name]" value="%s" size="40" placeholder="Your organization name" />', esc_attr($value));
1680 1698 },
1681 1699 $schema_page,
1682 1700 'metasync_schema_org'
1683 1701 );
@@ -1687,9 +1705,9 @@
1687 1705 'Organization URL',
1688 1706 function() use ($option_key) {
1689 1707 $schema = Metasync::get_option('schema') ?? [];
1690 1708 $value = $schema['org_url'] ?? '';
1691 - printf('<input type="url" name="' . $option_key . '[schema][org_url]" value="%s" size="40" placeholder="https://example.com" />', esc_attr($value));
1709 + printf('<input type="url" name="' . esc_attr($option_key) . '[schema][org_url]" value="%s" size="40" placeholder="https://example.com" />', esc_attr($value));
1692 1710 },
1693 1711 $schema_page,
1694 1712 'metasync_schema_org'
1695 1713 );
@@ -1699,9 +1717,9 @@
1699 1717 'Organization Logo URL' . Metasync::get_tooltip_icon_html('schema_org_logo_tooltip', 'A direct link to your logo image. Google uses this in your Organization knowledge panel — use a clear, square-ish image on a plain background, ideally at least 112x112 pixels.'),
1700 1718 function() use ($option_key) {
1701 1719 $schema = Metasync::get_option('schema') ?? [];
1702 1720 $value = $schema['org_logo'] ?? '';
1703 - printf('<input type="url" name="' . $option_key . '[schema][org_logo]" value="%s" size="40" placeholder="https://example.com/logo.png" />', esc_attr($value));
1721 + printf('<input type="url" name="' . esc_attr($option_key) . '[schema][org_logo]" value="%s" size="40" placeholder="https://example.com/logo.png" />', esc_attr($value));
1704 1722 },
1705 1723 $schema_page,
1706 1724 'metasync_schema_org'
1707 1725 );
@@ -1711,9 +1729,9 @@
1711 1729 'Contact Telephone',
1712 1730 function() use ($option_key) {
1713 1731 $schema = Metasync::get_option('schema') ?? [];
1714 1732 $value = $schema['org_contact_telephone'] ?? '';
1715 - printf('<input type="text" name="' . $option_key . '[schema][org_contact_telephone]" value="%s" size="30" placeholder="+1-555-1234" />', esc_attr($value));
1733 + printf('<input type="text" name="' . esc_attr($option_key) . '[schema][org_contact_telephone]" value="%s" size="30" placeholder="+1-555-1234" />', esc_attr($value));
1716 1734 },
1717 1735 $schema_page,
1718 1736 'metasync_schema_org'
1719 1737 );
@@ -1723,9 +1741,9 @@
1723 1741 'Contact Type' . Metasync::get_tooltip_icon_html('schema_org_contact_type_tooltip', 'What this phone number is for, using schema.org wording such as "customer support", "sales", or "technical support". It tells search engines how the number should be labeled.'),
1724 1742 function() use ($option_key) {
1725 1743 $schema = Metasync::get_option('schema') ?? [];
1726 1744 $value = $schema['org_contact_type'] ?? '';
1727 - printf('<input type="text" name="' . $option_key . '[schema][org_contact_type]" value="%s" size="30" placeholder="customer support" />', esc_attr($value));
1745 + printf('<input type="text" name="' . esc_attr($option_key) . '[schema][org_contact_type]" value="%s" size="30" placeholder="customer support" />', esc_attr($value));
1728 1746 },
1729 1747 $schema_page,
1730 1748 'metasync_schema_org'
1731 1749 );
@@ -1735,9 +1753,9 @@
1735 1753 'Social Profile URLs' . Metasync::get_tooltip_icon_html('schema_org_same_as_tooltip', 'Links to your official profiles on other sites (Facebook, X/Twitter, LinkedIn, Wikipedia, etc.), one per line. This is the schema.org "sameAs" property — it helps search engines confirm these accounts are really you.'),
1736 1754 function() use ($option_key) {
1737 1755 $schema = Metasync::get_option('schema') ?? [];
1738 1756 $value = $schema['org_same_as'] ?? '';
1739 - printf('<textarea name="' . $option_key . '[schema][org_same_as]" rows="4" cols="50" placeholder="One URL per line&#10;https://facebook.com/...&#10;https://twitter.com/...">%s</textarea>', esc_textarea($value));
1757 + printf('<textarea name="' . esc_attr($option_key) . '[schema][org_same_as]" rows="4" cols="50" placeholder="One URL per line&#10;https://facebook.com/...&#10;https://twitter.com/...">%s</textarea>', esc_textarea($value));
1740 1758 echo '<p class="description">Enter one social profile URL per line.</p>';
1741 1759 },
1742 1760 $schema_page,
1743 1761 'metasync_schema_org'
@@ -1757,9 +1775,9 @@
1757 1775 'WebSite Name',
1758 1776 function() use ($option_key) {
1759 1777 $schema = Metasync::get_option('schema') ?? [];
1760 1778 $value = $schema['website_name'] ?? '';
1761 - printf('<input type="text" name="' . $option_key . '[schema][website_name]" value="%s" size="40" placeholder="Your website name" />', esc_attr($value));
1779 + printf('<input type="text" name="' . esc_attr($option_key) . '[schema][website_name]" value="%s" size="40" placeholder="Your website name" />', esc_attr($value));
1762 1780 },
1763 1781 $schema_page,
1764 1782 'metasync_schema_website'
1765 1783 );
@@ -1769,9 +1787,9 @@
1769 1787 'WebSite URL',
1770 1788 function() use ($option_key) {
1771 1789 $schema = Metasync::get_option('schema') ?? [];
1772 1790 $value = $schema['website_url'] ?? '';
1773 - printf('<input type="url" name="' . $option_key . '[schema][website_url]" value="%s" size="40" placeholder="https://example.com" />', esc_attr($value));
1791 + printf('<input type="url" name="' . esc_attr($option_key) . '[schema][website_url]" value="%s" size="40" placeholder="https://example.com" />', esc_attr($value));
1774 1792 },
1775 1793 $schema_page,
1776 1794 'metasync_schema_website'
1777 1795 );
@@ -1782,9 +1800,9 @@
1782 1800 function() use ($option_key) {
1783 1801 $schema = Metasync::get_option('schema') ?? [];
1784 1802 $value = $schema['website_searchbox'] ?? false;
1785 1803 printf(
1786 - '<input type="checkbox" name="' . $option_key . '[schema][website_searchbox]" value="1" %s />',
1804 + '<input type="checkbox" name="' . esc_attr($option_key) . '[schema][website_searchbox]" value="1" %s />',
1787 1805 checked($value, true, false)
1788 1806 );
1789 1807 echo '<span class="description">Adds SearchAction to WebSite schema for Google Sitelinks Searchbox.</span>';
1790 1808 },
@@ -1807,9 +1825,9 @@
1807 1825 function() use ($option_key) {
1808 1826 $schema = Metasync::get_option('schema') ?? [];
1809 1827 $value = $schema['override_woocommerce_schema'] ?? false;
1810 1828 printf(
1811 - '<input type="checkbox" name="' . $option_key . '[schema][override_woocommerce_schema]" value="1" %s />',
1829 + '<input type="checkbox" name="' . esc_attr($option_key) . '[schema][override_woocommerce_schema]" value="1" %s />',
1812 1830 checked($value, true, false)
1813 1831 );
1814 1832 echo '<span class="description">When unchecked (default), MetaSync suppresses its Product schema when WooCommerce is active to avoid duplication. Check to override WooCommerce\'s native Product schema.</span>';
1815 1833 },
@@ -1863,9 +1881,9 @@
1863 1881 'baidu_site_verification',
1864 1882 'Baidu' . Metasync::get_tooltip_icon_html('baidu_site_verification_tooltip', 'Paste the verification code Baidu Webmaster gave you. This lets Baidu confirm you own this site — it does not change anything visitors see.'),
1865 1883 function() use ($option_key) {
1866 1884 $value = Metasync::get_option('searchengines')['baidu_site_verification'] ?? '';
1867 - printf( '<input type="text" id="baidu_site_verification" name="' . $option_key . '[searchengines][baidu_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1885 + printf( '<input type="text" id="baidu_site_verification" name="' . esc_attr($option_key) . '[searchengines][baidu_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1868 1886 },
1869 1887 $page_slug . '_searchengines-verification',
1870 1888 $SECTION_SEARCHENGINE
1871 1889 );
@@ -1874,9 +1892,9 @@
1874 1892 'alexa_site_verification',
1875 1893 'Alexa' . Metasync::get_tooltip_icon_html('alexa_site_verification_tooltip', 'Paste the verification code your Alexa/site directory gave you. This confirms you own this site — it does not change anything visitors see.'),
1876 1894 function() use ($option_key) {
1877 1895 $value = Metasync::get_option('searchengines')['alexa_site_verification'] ?? '';
1878 - printf( '<input type="text" id="alexa_site_verification" name="' . $option_key . '[searchengines][alexa_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1896 + printf( '<input type="text" id="alexa_site_verification" name="' . esc_attr($option_key) . '[searchengines][alexa_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1879 1897 },
1880 1898 $page_slug . '_searchengines-verification',
1881 1899 $SECTION_SEARCHENGINE
1882 1900 );
@@ -1885,9 +1903,9 @@
1885 1903 'norton_save_site_verification',
1886 1904 'Norton Safe Web' . Metasync::get_tooltip_icon_html('norton_site_verification_tooltip', 'Paste the verification code Norton Safe Web gave you. This confirms you own this site — it does not change anything visitors see.'),
1887 1905 function() use ($option_key) {
1888 1906 $value = Metasync::get_option('searchengines')['norton_save_site_verification'] ?? '';
1889 - printf( '<input type="text" id="norton_save_site_verification" name="' . $option_key . '[searchengines][norton_save_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1907 + printf( '<input type="text" id="norton_save_site_verification" name="' . esc_attr($option_key) . '[searchengines][norton_save_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1890 1908 },
1891 1909 $page_slug . '_searchengines-verification',
1892 1910 $SECTION_SEARCHENGINE
1893 1911 );
@@ -2053,8 +2071,71 @@
2053 2071 return $base;
2054 2072 }
2055 2073
2056 2074 /**
2075 + * Enforce the read-only contract on general.otto_pixel_uuid.
2076 + *
2077 + * The UUID is owned by trusted Search Atlas flows and explicit disconnect.
2078 + * Any changed value submitted through the settings form is rejected and the
2079 + * stored UUID is preserved verbatim. Programmatic trusted writers bypass
2080 + * this form-submission guard.
2081 + *
2082 + * @param array $merged The layered-merge result about to be saved.
2083 + * @param array $stored The stored option, used as the preserved value.
2084 + * @return array The merged array with the stored otto_pixel_uuid kept.
2085 + */
2086 + private static function protect_otto_pixel_uuid(array $merged, array $stored)
2087 + {
2088 + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2089 + if (!isset($_POST['metasync_options']['general'])
2090 + || !is_array($_POST['metasync_options']['general'])
2091 + || !array_key_exists('otto_pixel_uuid', $_POST['metasync_options']['general'])) {
2092 + return $merged;
2093 + }
2094 +
2095 + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2096 + $submitted = $_POST['metasync_options']['general']['otto_pixel_uuid'];
2097 + if (is_string($submitted)) {
2098 + $submitted = sanitize_text_field(wp_unslash($submitted));
2099 + }
2100 + $candidate = $merged['general']['otto_pixel_uuid'] ?? null;
2101 +
2102 + // A trusted flow may have replaced the submitted value after the form
2103 + // was posted; such a value is not user-authored and must survive.
2104 + // The trust inference must NOT rest on the inequality alone: the
2105 + // merged candidate comes from the raw submission layer and can differ
2106 + // from the sanitized $submitted merely because sanitization alters
2107 + // the payload (tags, line breaks, repeated spaces). A mismatch on
2108 + // its own must never authorize storing the raw value, so only a
2109 + // sanctioned shape may pass through: the empty string (the
2110 + // disconnected state) or a strictly valid UUID — the only values
2111 + // trusted writers ever set. Everything else falls back to stored.
2112 + if ($candidate !== $submitted
2113 + && ($candidate === '' || Metasync::is_valid_uuid($candidate))) {
2114 + return $merged;
2115 + }
2116 +
2117 + $stored_value = $stored['general']['otto_pixel_uuid'] ?? null;
2118 + if ($candidate === $stored_value) {
2119 + return $merged;
2120 + }
2121 +
2122 + if (is_string($stored_value)) {
2123 + $merged['general']['otto_pixel_uuid'] = $stored_value;
2124 + } else {
2125 + unset($merged['general']['otto_pixel_uuid']);
2126 + }
2127 +
2128 + add_settings_error(
2129 + 'metasync_messages',
2130 + 'metasync_otto_pixel_uuid_readonly',
2131 + __('OTTO Pixel UUID is read-only and can only be assigned by the Search Atlas / heartbeat synchronization.', 'metasync'),
2132 + 'error'
2133 + );
2134 + return $merged;
2135 + }
2136 +
2137 + /**
2057 2138 * Whether the array is a numerically-indexed list (keys 0..n-1).
2058 2139 *
2059 2140 * Answers false for the empty array — range(0, -1) counts downwards and
2060 2141 * yields [0, -1], not []. That is load-bearing rather than incidental: an
@@ -2103,10 +2184,10 @@
2103 2184 * @return array Sanitized settings
2104 2185 */
2105 2186 public function sanitize($input)
2106 2187 {
2107 - $submitted_generation = isset($_POST['metasync_pw_reset_generation'])
2108 - ? absint($_POST['metasync_pw_reset_generation'])
2188 + $submitted_generation = isset($_POST['metasync_pw_reset_generation']) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2189 + ? absint($_POST['metasync_pw_reset_generation']) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2109 2190 : null;
2110 2191 // Legacy/stale forms that include a password but no reset generation
2111 2192 // must never be allowed to overwrite a newer recovery password.
2112 2193 if (!self::$allow_recovery_password_write
@@ -2150,15 +2231,15 @@
2150 2231
2151 2232 # Determine which tab is being submitted (same logic as AJAX handler)
2152 2233 # Check POST first (from AJAX), then GET (from form action URL)
2153 2234 $active_tab = 'general';
2154 - if (isset($_POST['active_tab'])) {
2155 - $active_tab = sanitize_text_field($_POST['active_tab']);
2156 - } elseif (isset($_GET['tab'])) {
2157 - $active_tab = sanitize_text_field($_GET['tab']);
2158 - } elseif (isset($_POST['_wp_http_referer'])) {
2235 + if (isset($_POST['active_tab'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2236 + $active_tab = sanitize_text_field(wp_unslash($_POST['active_tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2237 + } elseif (isset($_GET['tab'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- sanitize callback, the caller already verified the nonce
2238 + $active_tab = sanitize_text_field(wp_unslash($_GET['tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- sanitize callback, the caller already verified the nonce
2239 + } elseif (isset($_POST['_wp_http_referer'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2159 2240 # Parse referer URL to get tab parameter
2160 - $referer = wp_parse_url($_POST['_wp_http_referer']);
2241 + $referer = wp_parse_url(esc_url_raw(wp_unslash($_POST['_wp_http_referer']))); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2161 2242 if (isset($referer['query'])) {
2162 2243 parse_str($referer['query'], $referer_params);
2163 2244 if (isset($referer_params['tab'])) {
2164 2245 $active_tab = sanitize_text_field($referer_params['tab']);
@@ -2165,8 +2246,9 @@
2165 2246 }
2166 2247 }
2167 2248 }
2168 2249 $general_tab_submitted = ($active_tab === 'general');
2250 + $compatibility_tab_submitted = ($active_tab === 'compatibility');
2169 2251
2170 2252 // General Settings
2171 2253 if (isset($input['general']['apikey'])) {
2172 2254 $new_input['general']['apikey'] = sanitize_text_field($input['general']['apikey']);
@@ -2171,8 +2253,26 @@
2171 2253 if (isset($input['general']['apikey'])) {
2172 2254 $new_input['general']['apikey'] = sanitize_text_field($input['general']['apikey']);
2173 2255 }
2174 2256
2257 + # Plugin-compatibility toggles live on the Compatibility page, which
2258 + # saves through options.php with a hidden active_tab=compatibility so
2259 + # the General-tab-gated blocks below never fire. Sanitize the two
2260 + # toggles explicitly so the raw submission never reaches storage
2261 + # unsanitized, and so a value that is absent (toggle hidden because
2262 + # its plugin is not active) keeps its stored value via the merge.
2263 + if ($compatibility_tab_submitted) {
2264 + if (isset($input['general']['otto_wp_rocket_compat'])) {
2265 + $new_input['general']['otto_wp_rocket_compat'] = sanitize_text_field($input['general']['otto_wp_rocket_compat']);
2266 + }
2267 + if (isset($input['general']['otto_sg_optimizer_compat'])) {
2268 + $new_input['general']['otto_sg_optimizer_compat'] = sanitize_text_field($input['general']['otto_sg_optimizer_compat']);
2269 + }
2270 + if (isset($input['general']['bookingpress_session_compat'])) {
2271 + $new_input['general']['bookingpress_session_compat'] = sanitize_text_field($input['general']['bookingpress_session_compat']);
2272 + }
2273 + }
2274 +
2175 2275 // Meta Box Visibility Settings
2176 2276 if (isset($input['general']) && $general_tab_submitted) {
2177 2277 $checkbox_fields = [
2178 2278 'disable_common_robots_metabox',
@@ -2673,21 +2773,16 @@
2673 2773 }
2674 2774 }
2675 2775 }
2676 2776
2677 - // Indexation Control Settings
2678 - if (isset($input['seo_controls']['index_date_archives'])) {
2679 - $new_input['seo_controls']['index_date_archives'] = boolval($input['seo_controls']['index_date_archives']);
2777 + // Indexation Control Settings. Stored as the strings 'true'/'false' (the form,
2778 + // the setup wizard and count_index_rule_changes() all compare against those);
2779 + // boolval('false') is true, which silently turned every unchecked box on.
2780 + foreach (array('index_date_archives', 'index_tag_archives', 'index_author_archives', 'index_format_archives') as $index_field) {
2781 + if (isset($input['seo_controls'][$index_field])) {
2782 + $new_input['seo_controls'][$index_field] = filter_var($input['seo_controls'][$index_field], FILTER_VALIDATE_BOOLEAN) ? 'true' : 'false';
2783 + }
2680 2784 }
2681 - if (isset($input['seo_controls']['index_tag_archives'])) {
2682 - $new_input['seo_controls']['index_tag_archives'] = boolval($input['seo_controls']['index_tag_archives']);
2683 - }
2684 - if (isset($input['seo_controls']['index_author_archives'])) {
2685 - $new_input['seo_controls']['index_author_archives'] = boolval($input['seo_controls']['index_author_archives']);
2686 - }
2687 - if (isset($input['seo_controls']['index_format_archives'])) {
2688 - $new_input['seo_controls']['index_format_archives'] = boolval($input['seo_controls']['index_format_archives']);
2689 - }
2690 2785
2691 2786 // Handle post-save heartbeat trigger for domain changes
2692 2787 if (isset($new_input['_trigger_heartbeat_after_save'])) {
2693 2788 $context = $new_input['_trigger_heartbeat_after_save'];
@@ -2723,13 +2818,71 @@
2723 2818 if (isset($input['whitelabel'], $new_input['whitelabel'])) {
2724 2819 $input['whitelabel'] = $new_input['whitelabel'];
2725 2820 }
2726 2821
2822 + // The branding contract must also hold on the native options.php
2823 + // submit (administrators with JavaScript disabled or broken — the
2824 + // AJAX handler never runs for them): a value that could not be
2825 + // written to metasync.php must not be persisted either. A rejected
2826 + // key is stripped from the raw layer so the layered merge keeps the
2827 + // stored value, leaving options and the physical header untouched.
2828 + // The AJAX save and the JSON import already enforce this contract
2829 + // before their writes, so for them this is a consistent no-op.
2830 + if (!class_exists('Metasync_Activator')) {
2831 + // Defensive only: real requests always have the activator via the
2832 + // plugin boot / Composer classmap. Plain dirname() (not
2833 + // plugin_dir_path()) keeps this loadable in the isolated unit
2834 + // harnesses that define neither.
2835 + $activator_file = dirname(__FILE__) . '/class-metasync-activator.php';
2836 + if (is_readable($activator_file)) {
2837 + require_once $activator_file;
2838 + }
2839 + }
2840 + if (class_exists('Metasync_Activator') && isset($input['general']) && is_array($input['general'])) {
2841 + $branding_contract_fields = array(
2842 + 'white_label_plugin_name' => false,
2843 + 'white_label_plugin_description' => false,
2844 + 'white_label_plugin_author' => false,
2845 + 'white_label_plugin_author_uri' => true,
2846 + 'white_label_plugin_uri' => true,
2847 + );
2848 + foreach ($branding_contract_fields as $branding_key => $branding_is_url) {
2849 + if (!array_key_exists($branding_key, $input['general']) || !is_string($input['general'][$branding_key])) {
2850 + continue;
2851 + }
2852 + $branding_value = trim(wp_unslash($input['general'][$branding_key]));
2853 + if ($branding_value === ''
2854 + || Metasync_Activator::is_valid_plugin_header_value($branding_value, $branding_is_url)) {
2855 + continue;
2856 + }
2857 + add_settings_error(
2858 + 'metasync_messages',
2859 + 'metasync_branding_' . $branding_key,
2860 + $branding_is_url
2861 + ? sprintf('%s must be a valid http:// or https:// address.', $branding_key === 'white_label_plugin_author_uri' ? 'Author URL' : 'Plugin URL')
2862 + : sprintf('%s contains characters that cannot be saved (line breaks, control or invisible characters, "*/", or PHP tags are not allowed).', $branding_key === 'white_label_plugin_name' ? 'Plugin Name' : ($branding_key === 'white_label_plugin_description' ? 'Plugin Description' : 'Author')),
2863 + 'error'
2864 + );
2865 + unset($input['general'][$branding_key]);
2866 + }
2867 + }
2868 +
2727 2869 // Layered merge: stored option, then the raw submission, then the
2728 2870 // sanitized values. Replaces the former shallow array_merge(), which
2729 2871 // let the raw submission overwrite every sanitized section wholesale.
2730 2872 $result = self::merge_option_layers($stored_option, $input, $new_input);
2731 2873
2874 + // otto_pixel_uuid is read-only. The field is populated only by
2875 + // the trusted Search Atlas / heartbeat flows, and sanitize_option runs
2876 + // on every update_option() of this option — including the AJAX save and
2877 + // a forged POST that smuggles the key in — so this is the last line of
2878 + // defence for the shape of the stored value. An empty string (the
2879 + // disconnected state) and a valid UUID pass through; anything else
2880 + // (a username or other arbitrary text) falls back to the stored value.
2881 + // The disconnect flow is unaffected: it unsets the key and writes
2882 + // through write_options_without_stored_merge(), never through here.
2883 + $result = self::protect_otto_pixel_uuid($result, $stored_option);
2884 +
2732 2885 if ($whitelabel_reset) {
2733 2886 $result['whitelabel'] = $new_input['whitelabel'];
2734 2887 }
2735 2888
@@ -2748,9 +2901,10 @@
2748 2901 * Moved from Metasync_Admin – the admin class now delegates here.
2749 2902 */
2750 2903 public function meta_sync_save_settings()
2751 2904 {
2752 - if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce($_POST['meta_sync_nonce'], 'meta_sync_general_setting_nonce')) {
2905 + $meta_sync_nonce = isset($_POST['meta_sync_nonce']) ? sanitize_text_field(wp_unslash($_POST['meta_sync_nonce'])) : '';
2906 + if (!wp_verify_nonce($meta_sync_nonce, 'meta_sync_general_setting_nonce')) {
2753 2907 wp_send_json_error(array('message' => 'Invalid nonce'));
2754 2908 return;
2755 2909 }
2756 2910
@@ -2787,9 +2941,12 @@
2787 2941 'white_label_plugin_name', 'white_label_plugin_description',
2788 2942 'white_label_plugin_author', 'white_label_plugin_menu_slug',
2789 2943 'white_label_plugin_menu_icon', 'enabled_plugin_css',
2790 2944 'enabled_elementor_plugin_css_color','enabled_elementor_plugin_css',
2791 - 'otto_pixel_uuid','periodic_clear_otto_cache','periodic_clear_ottopage_cache',
2945 + // The OTTO Pixel UUID is deliberately NOT listed here. It is
2946 + // read-only and populated only by the trusted Search Atlas /
2947 + // heartbeat flows; sanitize() preserves the stored value.
2948 + 'periodic_clear_otto_cache','periodic_clear_ottopage_cache',
2792 2949 'periodic_clear_ottopost_cache', 'whitelabel_otto_name', 'otto_wp_rocket_compat',
2793 2950 'otto_sg_optimizer_compat',
2794 2951 'bookingpress_session_compat'
2795 2952 ];
@@ -2797,9 +2954,9 @@
2797 2954 $url_fields = [
2798 2955 'white_label_plugin_author_uri', 'white_label_plugin_uri'
2799 2956 ];
2800 2957
2801 - $bool_fields = ['otto_disable_on_loggedin', 'otto_disable_preview_button', 'otto_disable_for_bots' , 'hide_dashboard_framework', 'show_admin_bar_status', 'enable_auto_updates', 'disable_common_robots_metabox', 'disable_advance_robots_metabox', 'disable_redirection_metabox', 'disable_canonical_metabox', 'disable_social_opengraph_metabox', 'disable_schema_markup_metabox', 'disable_language_alternates_metabox', 'disable_seo_metabox', 'open_external_links', Metasync_Seo_Backup::CONSENT_OPTION_KEY];
2958 + $bool_fields = ['otto_disable_on_loggedin', 'otto_disable_preview_button', 'otto_disable_for_bots' , 'hide_dashboard_framework', 'show_admin_bar_status','disable_common_robots_metabox', 'disable_advance_robots_metabox', 'disable_redirection_metabox', 'disable_canonical_metabox', 'disable_social_opengraph_metabox', 'disable_schema_markup_metabox', 'disable_language_alternates_metabox', 'disable_seo_metabox', 'open_external_links', Metasync_Seo_Backup::CONSENT_OPTION_KEY];
2802 2959
2803 2960 $url_fields = ['white_label_plugin_author_uri', 'white_label_plugin_uri'];
2804 2961
2805 2962 $metasync_options = Metasync::get_option();
@@ -2811,9 +2968,9 @@
2811 2968 }
2812 2969
2813 2970 $validation_errors = [];
2814 2971
2815 - $active_tab = isset($_POST['active_tab']) ? sanitize_text_field($_POST['active_tab']) : 'general';
2972 + $active_tab = isset($_POST['active_tab']) ? sanitize_text_field(wp_unslash($_POST['active_tab'])) : 'general';
2816 2973 $general_tab_submitted = ($active_tab === 'general');
2817 2974 $whitelabel_tab_submitted = ($active_tab === 'whitelabel');
2818 2975
2819 2976 // Whitelabel field validation issues are collected per-field and reported back
@@ -2819,13 +2976,27 @@
2819 2976 // Whitelabel field validation issues are collected per-field and reported back
2820 2977 // without blocking the rest of the settings save.
2821 2978 $whitelabel_errors = [];
2822 2979
2980 + // Branding field errors use the same contract as the physical
2981 + // plugin-header updater; they are blocking because a value that cannot
2982 + // be written to metasync.php must not be persisted to the options
2983 + // either (that divergence is exactly what WP-636 closes).
2984 + $branding_errors = [];
2985 +
2986 + // The five white-label branding fields share the plugin-header
2987 + // validation contract, so the activator (which owns that contract)
2988 + // must be available before the field loops run.
2989 + if (!class_exists('Metasync_Activator')) {
2990 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-activator.php';
2991 + }
2992 +
2823 2993 foreach ($text_fields as $field) {
2824 2994
2825 2995 if (isset($_POST['metasync_options']['general'][$field])) {
2826 2996
2827 - $value = trim($_POST['metasync_options']['general'][$field]);
2997 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every storing branch below sanitises via sanitize_text_field/sanitize_title/esc_url_raw before the value reaches the option.
2998 + $value = trim(wp_unslash($_POST['metasync_options']['general'][$field]));
2828 2999
2829 3000 // searchatlas_api_key must be clearable to support key removal
2830 3001 $clearable_fields = [
2831 3002 'searchatlas_api_key',
@@ -2833,10 +3004,9 @@
2833 3004 'white_label_plugin_description',
2834 3005 'white_label_plugin_author',
2835 3006 'white_label_plugin_menu_slug',
2836 3007 'white_label_plugin_menu_icon',
2837 - 'whitelabel_otto_name',
2838 - 'otto_pixel_uuid'
3008 + 'whitelabel_otto_name'
2839 3009 ];
2840 3010
2841 3011 if ($value === '' && !in_array($field, $clearable_fields)) {
2842 3012 continue;
@@ -2851,8 +3021,25 @@
2851 3021 continue;
2852 3022 }
2853 3023 }
2854 3024
3025 + // Branding text must satisfy the plugin-header contract. The
3026 + // raw value is checked before sanitisation so multiline or
3027 + // control-character input is rejected with an error instead of
3028 + // being silently mangled into something else that gets saved.
3029 + $branding_text_fields = [
3030 + 'white_label_plugin_name' => 'Plugin Name',
3031 + 'white_label_plugin_description' => 'Plugin Description',
3032 + 'white_label_plugin_author' => 'Author',
3033 + ];
3034 + if (isset($branding_text_fields[$field])
3035 + && $value !== ''
3036 + && !Metasync_Activator::is_valid_plugin_header_value($value, false)) {
3037 + $branding_errors[$field] = $branding_text_fields[$field]
3038 + . ' contains characters that cannot be saved (line breaks, control or invisible characters, "*/", or PHP tags are not allowed).';
3039 + continue;
3040 + }
3041 +
2855 3042 if ($field === 'white_label_plugin_menu_icon') {
2856 3043
2857 3044 if ($value === '') {
2858 3045 $metasync_options['general'][$field] = '';
@@ -2862,9 +3049,9 @@
2862 3049 } elseif (filter_var($value, FILTER_VALIDATE_URL)) {
2863 3050
2864 3051 $image_extensions = ['png', 'svg'];
2865 3052
2866 - $path = parse_url($value, PHP_URL_PATH);
3053 + $path = wp_parse_url($value, PHP_URL_PATH);
2867 3054 $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
2868 3055
2869 3056 if (in_array($extension, $image_extensions, true)) {
2870 3057 $metasync_options['general'][$field] = esc_url_raw($value);
@@ -2878,9 +3065,9 @@
2878 3065 // Self-correcting slug: a URL-shaped value is reduced to a valid WP
2879 3066 // menu slug so it can never break sidebar link generation.
2880 3067 $metasync_options['general'][$field] = sanitize_title($value);
2881 3068 } else {
2882 - $metasync_options['general'][$field] = sanitize_text_field($_POST['metasync_options']['general'][$field]);
3069 + $metasync_options['general'][$field] = sanitize_text_field(wp_unslash($_POST['metasync_options']['general'][$field]));
2883 3070 }
2884 3071 }
2885 3072 }
2886 3073
@@ -2887,9 +3074,9 @@
2887 3074 $textarea_fields = ['otto_bot_whitelist', 'otto_bot_blacklist'];
2888 3075 if ($general_tab_submitted) {
2889 3076 foreach ($textarea_fields as $field) {
2890 3077 if (isset($_POST['metasync_options']['general'][$field])) {
2891 - $metasync_options['general'][$field] = sanitize_textarea_field($_POST['metasync_options']['general'][$field]);
3078 + $metasync_options['general'][$field] = sanitize_textarea_field(wp_unslash($_POST['metasync_options']['general'][$field]));
2892 3079 } else {
2893 3080 $metasync_options['general'][$field] = '';
2894 3081 }
2895 3082 }
@@ -2897,9 +3084,9 @@
2897 3084
2898 3085 if ($general_tab_submitted) {
2899 3086 foreach ($bool_fields as $field) {
2900 3087 if (isset($_POST['metasync_options']['general'][$field])) {
2901 - $metasync_options['general'][$field] = filter_var($_POST['metasync_options']['general'][$field], FILTER_VALIDATE_BOOLEAN);
3088 + $metasync_options['general'][$field] = filter_var(wp_unslash($_POST['metasync_options']['general'][$field]), FILTER_VALIDATE_BOOLEAN);
2902 3089 }else {
2903 3090 $metasync_options['general'][$field] = false;
2904 3091 }
2905 3092 }
@@ -2918,9 +3105,10 @@
2918 3105 if ($general_tab_submitted || $whitelabel_tab_submitted) {
2919 3106 foreach ($url_fields as $field) {
2920 3107 if (isset($_POST['metasync_options']['general'][$field])) {
2921 3108
2922 - $value = trim($_POST['metasync_options']['general'][$field]);
3109 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every storing branch below sanitises via sanitize_text_field/sanitize_title/esc_url_raw before the value reaches the option.
3110 + $value = trim(wp_unslash($_POST['metasync_options']['general'][$field]));
2923 3111
2924 3112 $whitelabel_clearable_url_fields = [
2925 3113 'white_label_plugin_author_uri',
2926 3114 'white_label_plugin_uri'
@@ -2936,20 +3124,38 @@
2936 3124 }
2937 3125
2938 3126 if (filter_var($value, FILTER_VALIDATE_URL)) {
2939 3127
2940 - $parsed_url = parse_url($value);
3128 + $parsed_url = wp_parse_url($value);
2941 3129 $host = isset($parsed_url['host']) ? $parsed_url['host'] : '';
2942 3130
2943 3131 if (strpos($host, '.') !== false || filter_var($host, FILTER_VALIDATE_IP)) {
2944 - $metasync_options['general'][$field] = esc_url_raw($value);
3132 + // Branding URLs must additionally satisfy the plugin-header
3133 + // contract (http/https scheme, no embedded control characters);
3134 + // anything else would be saved but silently fail to reach
3135 + // metasync.php, leaving options inconsistent with the Plugins
3136 + // screen branding.
3137 + $branding_url_field_names = [
3138 + 'white_label_plugin_author_uri' => 'Author URL',
3139 + 'white_label_plugin_uri' => 'Plugin URL',
3140 + ];
3141 + if (!Metasync_Activator::is_valid_plugin_header_value($value, true)) {
3142 + $branding_errors[$field] = $branding_url_field_names[$field]
3143 + . ' must be a valid http:// or https:// address.';
3144 + } else {
3145 + $metasync_options['general'][$field] = esc_url_raw($value);
3146 + }
2945 3147 } else {
2946 3148 $field_name = ($field === 'white_label_plugin_author_uri') ? 'Author URL' : 'Plugin URL';
2947 - $validation_errors[] = 'Invalid ' . $field_name . ' format. Please use a proper domain name (e.g., example.com).';
3149 + // Pushed into $branding_errors only: the blocking
3150 + // merge below appends every branding message to the
3151 + // response errors, so a second direct push would render
3152 + // the same message twice.
3153 + $branding_errors[$field] = 'Invalid ' . $field_name . ' format. Please use a proper domain name (e.g., example.com).';
2948 3154 }
2949 3155 } else {
2950 3156 $field_name = ($field === 'white_label_plugin_author_uri') ? 'Author URL' : 'Plugin URL';
2951 - $validation_errors[] = 'Invalid ' . $field_name . ' format.';
3157 + $branding_errors[$field] = $field_name . ' must be a valid http:// or https:// address.';
2952 3158 }
2953 3159 }
2954 3160 }
2955 3161 }
@@ -2955,9 +3161,9 @@
2955 3161 }
2956 3162
2957 3163 if ($general_tab_submitted) {
2958 3164 if (isset($_POST['metasync_options']['general']['content_genius_sync_roles']) && is_array($_POST['metasync_options']['general']['content_genius_sync_roles'])) {
2959 - $metasync_options['general']['content_genius_sync_roles'] = array_map('sanitize_text_field', $_POST['metasync_options']['general']['content_genius_sync_roles']);
3165 + $metasync_options['general']['content_genius_sync_roles'] = array_map('sanitize_text_field', wp_unslash($_POST['metasync_options']['general']['content_genius_sync_roles']));
2960 3166 } else {
2961 3167 $metasync_options['general']['content_genius_sync_roles'] = array();
2962 3168 }
2963 3169 }
@@ -2969,9 +3175,10 @@
2969 3175 if ($general_tab_submitted
2970 3176 && isset($_POST['metasync_options']['breadcrumbs'])
2971 3177 && is_array($_POST['metasync_options']['breadcrumbs'])
2972 3178 ) {
2973 - $breadcrumbs_input = $_POST['metasync_options']['breadcrumbs'];
3179 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each key is sanitised below (bool cast for flags, sanitize_text_field for the separator).
3180 + $breadcrumbs_input = wp_unslash($_POST['metasync_options']['breadcrumbs']);
2974 3181
2975 3182 if (!isset($metasync_options['breadcrumbs']) || !is_array($metasync_options['breadcrumbs'])) {
2976 3183 $metasync_options['breadcrumbs'] = array();
2977 3184 }
@@ -3022,9 +3229,9 @@
3022 3229 $metasync_options['common_meta_settings'] = [];
3023 3230 }
3024 3231 foreach ($og_toggle_fields as $field) {
3025 3232 $posted = isset($_POST['metasync_options']['common_meta_settings'][$field])
3026 - ? sanitize_text_field($_POST['metasync_options']['common_meta_settings'][$field])
3233 + ? sanitize_text_field(wp_unslash($_POST['metasync_options']['common_meta_settings'][$field]))
3027 3234 : '';
3028 3235 $metasync_options['common_meta_settings'][$field] = ($posted === 'true') ? 'true' : 'false';
3029 3236 }
3030 3237 }
@@ -3031,14 +3238,15 @@
3031 3238
3032 3239 if ($general_tab_submitted && isset($_POST['metasync_options']['general']['default_page_builder'])) {
3033 3240 require_once plugin_dir_path(dirname(__FILE__)) . 'custom-pages/class-metasync-html-to-builder-converter.php';
3034 3241 $available_builders = Metasync_HTML_To_Builder_Converter::get_available_builders();
3035 - $builder_value = sanitize_text_field($_POST['metasync_options']['general']['default_page_builder']);
3242 + $builder_value = sanitize_text_field(wp_unslash($_POST['metasync_options']['general']['default_page_builder']));
3036 3243 if (isset($available_builders[$builder_value]) && $available_builders[$builder_value]['detected']) {
3037 3244 $metasync_options['general']['default_page_builder'] = $builder_value;
3038 3245 } else {
3039 3246 $metasync_options['general']['default_page_builder'] = 'gutenberg';
3040 3247 if (isset($available_builders[$builder_value]) && !$available_builders[$builder_value]['detected']) {
3248 + /* translators: %s: page builder name. */
3041 3249 $validation_errors[] = sprintf(__('Builder "%s" is not detected on this site. Reverted to Gutenberg.', 'metasync'), $available_builders[$builder_value]['label']);
3042 3250 }
3043 3251 }
3044 3252 }
@@ -3043,10 +3251,13 @@
3043 3251 }
3044 3252 }
3045 3253
3046 3254 if ($whitelabel_tab_submitted && isset($_POST['metasync_options']['whitelabel'])) {
3047 - $gp = isset($_POST['metasync_options']['general']) && is_array($_POST['metasync_options']['general']) ? $_POST['metasync_options']['general'] : [];
3048 - $wp = isset($_POST['metasync_options']['whitelabel']) && is_array($_POST['metasync_options']['whitelabel']) ? $_POST['metasync_options']['whitelabel'] : [];
3255 + // Values are only previewed from the just-sanitised $metasync_options below; nothing here is stored.
3256 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- preview-only read
3257 + $gp = isset($_POST['metasync_options']['general']) && is_array($_POST['metasync_options']['general']) ? wp_unslash($_POST['metasync_options']['general']) : [];
3258 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- preview-only read
3259 + $wp = isset($_POST['metasync_options']['whitelabel']) && is_array($_POST['metasync_options']['whitelabel']) ? wp_unslash($_POST['metasync_options']['whitelabel']) : [];
3049 3260 $plugin_name = isset($gp['white_label_plugin_name']) ? trim((string) $gp['white_label_plugin_name']) : '';
3050 3261 $logo_light = isset($wp['logo_light']) ? trim((string) $wp['logo_light']) : '';
3051 3262 $logo_dark = isset($wp['logo_dark']) ? trim((string) $wp['logo_dark']) : '';
3052 3263 $author = isset($gp['white_label_plugin_author']) ? trim((string) $gp['white_label_plugin_author']) : '';
@@ -3062,17 +3273,67 @@
3062 3273 $validation_errors[] = 'Add at least one of: Plugin Name, Logo URL, or Author to save whitelabel settings.';
3063 3274 }
3064 3275 }
3065 3276
3277 + // Branding errors block the whole save: an invalid value must never
3278 + // be persisted, and the response carries the field keys so the client
3279 + // can render the error beside each offending input.
3280 + foreach ($branding_errors as $branding_error) {
3281 + $validation_errors[] = $branding_error;
3282 + }
3283 +
3066 3284 if (!empty($validation_errors)) {
3067 3285 $this->force_release_settings_recovery_lock();
3068 3286 wp_send_json_error([
3069 - 'errors' => $validation_errors
3287 + 'errors' => $validation_errors,
3288 + 'field_errors' => $branding_errors,
3070 3289 ]);
3071 3290 return;
3072 3291 }
3073 3292
3293 + // The analytics consent toggle posts at the top level (hidden fallback
3294 + // plus checkbox), not under metasync_options, so persist it here. This
3295 + // is the "turn it off at any time" path promised in the readme.
3296 + if ($general_tab_submitted && isset($_POST['metasync_analytics_opt_in'])) {
3297 + $consent = sanitize_key(wp_unslash($_POST['metasync_analytics_opt_in']));
3298 + update_option('metasync_analytics_opt_in', $consent === 'yes' ? 'yes' : 'no');
3299 + update_option('metasync_analytics_consent_explicit', 'yes');
3300 + }
3301 +
3074 3302 $old_options = Metasync::get_option('general') ?? [];
3303 +
3304 + // White-label branding is persisted twice — into the options and into
3305 + // the physical metasync.php header — so the header update is applied
3306 + // BEFORE any option write. On failure nothing has been persisted and
3307 + // both the file and the stored options still carry the previous
3308 + // branding; the save is rejected instead of ending up inconsistent.
3309 + $branding_headers_written = false;
3310 + $original_plugin_file_content = false;
3311 + $plugin_header_file = Metasync_Activator::plugin_header_file_path();
3312 + if ($general_tab_submitted || $whitelabel_tab_submitted) {
3313 + $original_plugin_file_content = file_get_contents($plugin_header_file);
3314 + $headers_applied = Metasync_Activator::apply_plugin_file_headers_for_save(
3315 + $metasync_options['general'],
3316 + is_array($old_options) ? $old_options : array()
3317 + );
3318 + if ($headers_applied === false) {
3319 + $this->force_release_settings_recovery_lock();
3320 + wp_send_json_error([
3321 + 'errors' => array(
3322 + 'White-label branding could not be written to the plugin file (metasync.php may be read-only or damaged). Nothing was saved; the previous branding is unchanged.',
3323 + ),
3324 + ]);
3325 + // The return is unreachable in WordPress (wp_send_json_error
3326 + // terminates the request) but keeps the handler safe under the
3327 + // non-terminating stubs the unit tests run with.
3328 + // @phpstan-ignore-next-line deadCode.unreachable
3329 + return;
3330 + }
3331 + if ($headers_applied === true && is_string($original_plugin_file_content)) {
3332 + $branding_headers_written = file_get_contents($plugin_header_file) !== $original_plugin_file_content;
3333 + }
3334 + }
3335 +
3075 3336 // Stored value is encrypted at rest (or legacy plaintext/empty). Keep it as-is
3076 3337 // for preserve/revert, and decrypt a separate copy for plaintext comparison.
3077 3338 $old_stored_api_key = $old_options['searchatlas_api_key'] ?? '';
3078 3339 $old_api_key = Metasync::get_searchatlas_api_key();
@@ -3116,14 +3377,19 @@
3116 3377
3117 3378 if ( $ping['connected'] === true ) {
3118 3379 $api_key_validated = true;
3119 3380 $is_connected = true;
3120 - if ( ! empty( $ping['otto_pixel_uuid'] ) ) {
3381 + // Trusted ping flow: only a real UUID is stored. The
3382 + // form itself can no longer submit this field, so this ping is
3383 + // one of the few remaining sanctioned write paths for it.
3384 + if ( ! empty( $ping['otto_pixel_uuid'] ) && Metasync::is_valid_uuid( $ping['otto_pixel_uuid'] ) ) {
3121 3385 $existing_uuid = $metasync_options['general']['otto_pixel_uuid'] ?? '';
3122 3386 if ( empty( $existing_uuid ) || $existing_uuid !== $ping['otto_pixel_uuid'] ) {
3123 3387 $metasync_options['general']['otto_pixel_uuid'] = sanitize_text_field( $ping['otto_pixel_uuid'] );
3124 3388 Metasync::set_option($metasync_options);
3125 3389 }
3390 + } elseif ( ! empty( $ping['otto_pixel_uuid'] ) ) {
3391 + error_log('MetaSync: ignoring invalid otto_pixel_uuid from connection ping (not a UUID)');
3126 3392 }
3127 3393 } else {
3128 3394 // POST succeeded but ping didn't confirm — keep the key, warn user
3129 3395 $api_key_validated = true;
@@ -3175,8 +3441,15 @@
3175 3441 $api_key_changed = $old_api_key !== $new_api_key;
3176 3442 $api_key_added = empty($old_api_key) && !empty($new_api_key);
3177 3443 $api_key_removed = !empty($old_api_key) && empty($new_api_key);
3178 3444
3445 + if ( $api_key_removed ) {
3446 + Metasync_PostHog::feature('searchatlas_disconnected', array(
3447 + 'method' => 'key_removed',
3448 + 'was_connected' => true,
3449 + ));
3450 + }
3451 +
3179 3452 if ( $api_key_validated === true ) {
3180 3453 # Use current_time('mysql') so the value is consistent with the
3181 3454 # cron heartbeat writer and renders correctly via time_elapsed_string.
3182 3455 $send_auth_token_timestamp = Metasync::get_option();
@@ -3210,9 +3483,10 @@
3210 3483 }
3211 3484
3212 3485 if (isset($_POST['metasync_options']['whitelabel'])) {
3213 3486
3214 - $whitelabel_data = $_POST['metasync_options']['whitelabel'];
3487 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every stored key below is validated/sanitised (FILTER_VALIDATE_URL + esc_url_raw for logos, sanitize_* for text).
3488 + $whitelabel_data = wp_unslash($_POST['metasync_options']['whitelabel']);
3215 3489 $existing_whitelabel = $metasync_options['whitelabel'] ?? [];
3216 3490
3217 3491 if (isset($whitelabel_data['logo'])) {
3218 3492 $logo_value = trim($whitelabel_data['logo']);
@@ -3253,9 +3527,9 @@
3253 3527 }
3254 3528 }
3255 3529
3256 3530 if (isset($whitelabel_data['settings_password'])) {
3257 - $password_value = (string) wp_unslash($whitelabel_data['settings_password']);
3531 + $password_value = (string) $whitelabel_data['settings_password'];
3258 3532
3259 3533 $hide_settings_enabled = isset($whitelabel_data['hide_settings']) && $whitelabel_data['hide_settings'] == '1';
3260 3534
3261 3535 if ($hide_settings_enabled && empty($password_value)) {
@@ -3353,9 +3627,9 @@
3353 3627
3354 3628 }
3355 3629
3356 3630 $redirect_url = isset($_GET['tab']) ?
3357 - admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-settings&tab='.$_GET['tab']) :
3631 + admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-settings&tab=' . sanitize_text_field(wp_unslash($_GET['tab']))) :
3358 3632 admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-settings');
3359 3633
3360 3634 // Also save edge cache settings if present in the payload (section renders inside this form)
3361 3635 if (class_exists('Metasync_Edge_Cache_Settings')) {
@@ -3362,8 +3636,9 @@
3362 3636 Metasync_Edge_Cache_Settings::save_from_post();
3363 3637 }
3364 3638
3365 3639 if ($general_tab_submitted && isset($_POST['metasync_llms_txt_settings'])) {
3640 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitised by sanitize_llms_txt_settings() before storage.
3366 3641 $llms_input = wp_unslash($_POST['metasync_llms_txt_settings']);
3367 3642 if (is_array($llms_input)) {
3368 3643 update_option('metasync_llms_txt_settings', $this->sanitize_llms_txt_settings($llms_input));
3369 3644 }
@@ -3368,8 +3643,37 @@
3368 3643 update_option('metasync_llms_txt_settings', $this->sanitize_llms_txt_settings($llms_input));
3369 3644 }
3370 3645 }
3371 3646
3647 + // The plugin file already carries the new branding; confirm the
3648 + // options write landed the same values. On divergence, roll the file
3649 + // back to the captured content so the Plugins screen never shows
3650 + // branding the stored options contradict, and report the save as
3651 + // rejected rather than a success.
3652 + if ($branding_headers_written) {
3653 + $stored_branding = Metasync::get_option('general');
3654 + $stored_branding = is_array($stored_branding) ? $stored_branding : array();
3655 + $proposed_branding = $metasync_options['general'];
3656 + foreach (Metasync_Activator::BRANDING_HEADER_FIELDS as $branding_key) {
3657 + if ((string) ($stored_branding[$branding_key] ?? '') !== (string) ($proposed_branding[$branding_key] ?? '')) {
3658 + $restored = Metasync_Activator::restore_plugin_file_content($plugin_header_file, $original_plugin_file_content);
3659 + $this->force_release_settings_recovery_lock();
3660 + wp_send_json_error([
3661 + 'errors' => array(
3662 + $restored
3663 + ? 'White-label branding could not be saved. The previous settings and plugin branding were left unchanged.'
3664 + : 'White-label branding could not be saved and the plugin file branding could not be rolled back (metasync.php may be read-only). Re-save once the file is writable so the plugin header matches the stored settings.',
3665 + ),
3666 + ]);
3667 + // The return is unreachable in WordPress (wp_send_json_error
3668 + // terminates the request) but keeps the handler safe under the
3669 + // non-terminating stubs the unit tests run with.
3670 + // @phpstan-ignore-next-line deadCode.unreachable
3671 + return;
3672 + }
3673 + }
3674 + }
3675 +
3372 3676 $response_payload = array(
3373 3677 'message' => 'Settings saved successfully!',
3374 3678 'redirect_url' => $redirect_url,
3375 3679 );
@@ -3415,9 +3719,10 @@
3415 3719 * Moved from Metasync_Admin – the admin class now delegates here.
3416 3720 */
3417 3721 public function ajax_save_execution_settings()
3418 3722 {
3419 - if (!isset($_POST['execution_settings_nonce']) || !wp_verify_nonce($_POST['execution_settings_nonce'], 'metasync_execution_settings_nonce')) {
3723 + $execution_nonce = isset($_POST['execution_settings_nonce']) ? sanitize_text_field(wp_unslash($_POST['execution_settings_nonce'])) : '';
3724 + if (!wp_verify_nonce($execution_nonce, 'metasync_execution_settings_nonce')) {
3420 3725 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
3421 3726 return;
3422 3727 }
3423 3728
@@ -4008,16 +4313,19 @@
4008 4313 public function ajax_save_headless_settings()
4009 4314 {
4010 4315 $prefix = self::HEADLESS_FIELD_PREFIX;
4011 4316
4012 - if (!isset($_POST[self::HEADLESS_NONCE_FIELD]) || !wp_verify_nonce($_POST[self::HEADLESS_NONCE_FIELD], self::HEADLESS_NONCE_ACTION)) {
4317 + $headless_nonce = isset($_POST[self::HEADLESS_NONCE_FIELD]) ? sanitize_text_field(wp_unslash($_POST[self::HEADLESS_NONCE_FIELD])) : '';
4318 + if (!wp_verify_nonce($headless_nonce, self::HEADLESS_NONCE_ACTION)) {
4013 4319 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
4014 4320 } elseif (!Metasync::current_user_has_plugin_access()) {
4015 4321 wp_send_json_error(array('message' => 'Insufficient permissions to save settings.'));
4016 4322 } else {
4323 + // phpcs:disable WordPress.Security.ValidatedSanitizedInput -- sanitised via Metasync_Headless_Config::normalize_domain() inside save_headless_settings().
4017 4324 $submitted_domain = isset($_POST[$prefix . 'frontend_domain'])
4018 4325 ? trim((string) wp_unslash($_POST[$prefix . 'frontend_domain']))
4019 4326 : '';
4327 + // phpcs:enable WordPress.Security.ValidatedSanitizedInput
4020 4328
4021 4329 $stored = $this->save_headless_settings($_POST);
4022 4330
4023 4331 if (!$this->headless_settings_persisted($stored)) {
@@ -4052,8 +4360,35 @@
4052 4360 }
4053 4361 }
4054 4362
4055 4363 /**
4364 + * How many indexation rules a SEO Controls save actually changed.
4365 + *
4366 + * Older saves stored real booleans, this handler stores 'true'/'false'
4367 + * strings, so the stored side is normalised before comparing.
4368 + *
4369 + * @param array $before Stored seo_controls before the save.
4370 + * @param array $after seo_controls as just saved ('true'/'false').
4371 + * @param array $fields Field names the save handled.
4372 + * @return int
4373 + */
4374 + public static function count_index_rule_changes(array $before, array $after, array $fields)
4375 + {
4376 + $changed = 0;
4377 + foreach ($fields as $field) {
4378 + if (strpos($field, 'index_') !== 0 && $field !== 'noindex_empty_archives') {
4379 + continue;
4380 + }
4381 + $was = filter_var($before[$field] ?? false, FILTER_VALIDATE_BOOLEAN);
4382 + $now = ($after[$field] ?? 'false') === 'true';
4383 + if ($was !== $now) {
4384 + $changed++;
4385 + }
4386 + }
4387 + return $changed;
4388 + }
4389 +
4390 + /**
4056 4391 * AJAX handler for saving SEO / Indexation Control settings.
4057 4392 *
4058 4393 * Moved from Metasync_Admin – the admin class now delegates here.
4059 4394 */
@@ -4059,13 +4394,26 @@
4059 4394 */
4060 4395 public function meta_sync_save_seo_controls()
4061 4396 {
4062 4397 try {
4063 - if (!isset($_POST['meta_sync_seo_controls_nonce']) || !wp_verify_nonce($_POST['meta_sync_seo_controls_nonce'], 'meta_sync_seo_controls_nonce')) {
4398 + $seo_controls_nonce = isset($_POST['meta_sync_seo_controls_nonce']) ? sanitize_text_field(wp_unslash($_POST['meta_sync_seo_controls_nonce'])) : '';
4399 + if (!wp_verify_nonce($seo_controls_nonce, 'meta_sync_seo_controls_nonce')) {
4064 4400 wp_send_json_error(array('message' => 'Invalid nonce'));
4065 4401 return;
4066 4402 }
4067 4403
4404 + // Same nonce-then-capability shape as the other settings save
4405 + // handlers: the nonce is not authorization, so a logged-in user
4406 + // without plugin access must still be refused here.
4407 + if (!Metasync::current_user_has_plugin_access()) {
4408 + wp_send_json_error(array('message' => 'Insufficient permissions.'), 403);
4409 + // The return is unreachable in WordPress (wp_send_json_error
4410 + // terminates the request) but keeps the handler safe under the
4411 + // non-terminating stubs the unit tests run with.
4412 + // @phpstan-ignore-next-line deadCode.unreachable
4413 + return;
4414 + }
4415 +
4068 4416 $current_options = Metasync::get_option();
4069 4417
4070 4418 $original_options = json_decode(json_encode($current_options), true);
4071 4419
@@ -4115,8 +4463,22 @@
4115 4463 $result = true;
4116 4464 }
4117 4465
4118 4466 if ($result && $bing_save_result) {
4467 + // Consent-gated analytics: only when an indexation rule really
4468 + // changed. Firing from the options sanitize callback counted
4469 + // every unrelated write of metasync_options as a change.
4470 + $rules_changed = self::count_index_rule_changes(
4471 + $original_options['seo_controls'] ?? array(),
4472 + $current_options['seo_controls'],
4473 + $seo_control_fields
4474 + );
4475 + if ($rules_changed > 0) {
4476 + Metasync_PostHog::feature('indexation_rule_changed', array(
4477 + 'rules_changed' => $rules_changed,
4478 + ));
4479 + }
4480 +
4119 4481 $success_message = 'Indexation Control settings saved successfully!';
4120 4482 if (isset($_POST['metasync_bing_api_key_inline'])) {
4121 4483 $success_message = 'Indexation Control and Bing Instant Indexing settings saved successfully!';
4122 4484 }
@@ -4131,10 +4493,9 @@
4131 4493 }
4132 4494 wp_send_json_error(array('message' => $error_message));
4133 4495 }
4134 4496 } catch (Exception $e) {
4135 - error_log('Indexation Control AJAX Exception: ' . $e->getMessage());
4136 - error_log('Indexation Control AJAX Stack trace: ' . $e->getTraceAsString());
4497 + error_log('Indexation Control AJAX Exception: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
4137 4498 wp_send_json_error(array('message' => 'Error saving settings: ' . $e->getMessage()));
4138 4499 }
4139 4500 }
4140 4501
@@ -4146,12 +4507,12 @@
4146 4507 * @return bool True on success, false on failure
4147 4508 */
4148 4509 public function save_bing_inline_settings_ajax()
4149 4510 {
4150 - $api_key = isset($_POST['metasync_bing_api_key_inline']) ? sanitize_text_field(wp_unslash($_POST['metasync_bing_api_key_inline'])) : '';
4151 - $endpoint = isset($_POST['metasync_bing_endpoint_inline']) ? sanitize_text_field(wp_unslash($_POST['metasync_bing_endpoint_inline'])) : 'indexnow';
4152 - $post_types = isset($_POST['metasync_bing_post_types_inline']) && is_array($_POST['metasync_bing_post_types_inline']) ? array_map('sanitize_title', wp_unslash($_POST['metasync_bing_post_types_inline'])) : [];
4153 - $disable_other_plugins = isset($_POST['metasync_bing_disable_other_plugins_inline']) ? true : false;
4511 + $api_key = isset($_POST['metasync_bing_api_key_inline']) ? sanitize_text_field(wp_unslash($_POST['metasync_bing_api_key_inline'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller verifies the SEO controls nonce first
4512 + $endpoint = isset($_POST['metasync_bing_endpoint_inline']) ? sanitize_text_field(wp_unslash($_POST['metasync_bing_endpoint_inline'])) : 'indexnow'; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller verifies the SEO controls nonce first
4513 + $post_types = isset($_POST['metasync_bing_post_types_inline']) && is_array($_POST['metasync_bing_post_types_inline']) ? array_map('sanitize_title', wp_unslash($_POST['metasync_bing_post_types_inline'])) : []; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller verifies the SEO controls nonce first
4514 + $disable_other_plugins = isset($_POST['metasync_bing_disable_other_plugins_inline']) ? true : false; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller verifies the SEO controls nonce first
4154 4515
4155 4516 $existing_settings = get_option('metasync_options_bing_instant_indexing', []);
4156 4517
4157 4518 $new_settings = [
@@ -4175,9 +4536,9 @@
4175 4536 $safe_key = sanitize_file_name( $api_key );
4176 4537 $file_path = ABSPATH . $safe_key . '.txt';
4177 4538 $real_path = realpath( ABSPATH );
4178 4539 if ( false === $real_path || 0 !== strpos( realpath( dirname( $file_path ) ), $real_path ) ) {
4179 - error_log( 'Bing IndexNow: Refusing to write outside ABSPATH' );
4540 + error_log( 'Bing IndexNow: Refusing to write outside ABSPATH' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
4180 4541 return false;
4181 4542 }
4182 4543 // serve the key virtually via template_redirect instead of
4183 4544 // writing a physical {key}.txt. An on-disk file is served statically
@@ -4190,9 +4551,9 @@
4190 4551 }
4191 4552 Metasync_Bing_Instant_Index::register_virtual_key($safe_key);
4192 4553
4193 4554 if (file_exists($file_path)) {
4194 - @unlink($file_path);
4555 + @wp_delete_file($file_path);
4195 4556 }
4196 4557
4197 4558 $current_options = Metasync::get_option();
4198 4559 if (!isset($current_options['seo_controls']['enable_binginstantindex']) || $current_options['seo_controls']['enable_binginstantindex'] !== 'true') {