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 +1308 -381 2.6.25 → 2.7.2 View file →
@@ -48,8 +48,9 @@
48 48
49 49 private function __construct() {
50 50 add_filter('pre_update_option_metasync_options', array($this, 'protect_recovery_password_update'), 10, 3);
51 51 add_action('updated_option_metasync_options', array($this, 'release_settings_recovery_lock'), PHP_INT_MAX, 0);
52 + add_action('wp_ajax_metasync_save_headless_settings', array($this, 'ajax_save_headless_settings'));
52 53 }
53 54
54 55 private function acquire_settings_recovery_lock()
55 56 {
@@ -179,12 +180,13 @@
179 180 */
180 181 private function handle_plugin_access_roles_save()
181 182 {
182 183 if (isset($_POST['save_plugin_access_roles']) && $_POST['save_plugin_access_roles'] === 'yes') {
183 - 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')) {
184 186
185 187 if (!Metasync::current_user_has_plugin_access()) {
186 - 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')));
187 189 exit;
188 190 }
189 191
190 192 self::$defer_settings_recovery_lock_release = true;
@@ -202,9 +204,9 @@
202 204 $metasync_options['general'] = array();
203 205 }
204 206
205 207 if (isset($_POST['plugin_access_roles']) && is_array($_POST['plugin_access_roles'])) {
206 - $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']));
207 209 } else {
208 210 $metasync_options['general']['plugin_access_roles'] = array();
209 211 }
210 212
@@ -210,13 +212,13 @@
210 212
211 213 Metasync::set_option($metasync_options);
212 214
213 215 $redirect_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=advanced&access_roles_saved=1');
214 - wp_redirect($redirect_url);
216 + wp_safe_redirect($redirect_url);
215 217 exit;
216 218 } else {
217 219 $redirect_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=advanced&access_roles_error=1&message=' . urlencode('Invalid security token'));
218 - wp_redirect($redirect_url);
220 + wp_safe_redirect($redirect_url);
219 221 exit;
220 222 }
221 223 }
222 224 }
@@ -247,9 +249,8 @@
247 249 $SECTION_SEO_CONTROLS = Metasync_Admin::SECTION_SEO_CONTROLS;
248 250 $SECTION_SEO_CONTROLS_ADVANCED = Metasync_Admin::SECTION_SEO_CONTROLS_ADVANCED;
249 251 $SECTION_SEO_CONTROLS_INSTANT_INDEX = Metasync_Admin::SECTION_SEO_CONTROLS_INSTANT_INDEX;
250 252 $SECTION_PLUGIN_VISIBILITY = Metasync_Admin::SECTION_PLUGIN_VISIBILITY;
251 - $SECTION_BREADCRUMBS = Metasync_Admin::SECTION_BREADCRUMBS;
252 253 $SECTION_LLMS_TXT = Metasync_Admin::SECTION_LLMS_TXT;
253 254
254 255 $option_key = Metasync_Admin::option_key;
255 256 $page_slug = Metasync_Admin::$page_slug;
@@ -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. A new "Instant Indexing" menu item will appear in the navigation.</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 },
@@ -732,71 +755,123 @@
732 755 $page_slug . '_general',
733 756 $SECTION_METASYNC
734 757 );
735 758
736 - # WP Rocket compatibility mode setting
759 + # Global SEO title & description priority.
760 + #
761 + # Unchecked (the default) is the behaviour every site already runs: a
762 + # value the customer typed in the sidebar outranks OTTO's suggestion.
763 + # Checked hands precedence to OTTO, which is what makes a successful
764 + # OTTO deployment visible on a page that also holds a custom value.
765 + #
766 + # This only re-orders what RENDERS. Nothing is written to or deleted
767 + # from the stored post meta, so unchecking it restores the custom value
768 + # untouched. Applies to the SEO title, meta description and focus
769 + # keyword.
737 770 add_settings_field(
738 - 'otto_wp_rocket_compat',
739 - 'WP Rocket Compatibility',
771 + 'seo_priority',
772 + 'SEO Meta Priority',
740 773 function() use ($whitelabel_otto_name, $option_key) {
741 - $value = Metasync::get_option('general')['otto_wp_rocket_compat'] ?? 'auto';
742 - $wp_rocket_active = class_exists('WP_Rocket');
774 + $seo_priority = Metasync::get_option('general')['seo_priority'] ?? '';
775 + $checked = $seo_priority === Metasync_Seo_Precedence::PRIORITY_OTTO ? 'checked' : '';
776 + $name = $option_key . '[general][seo_priority]';
743 777
744 - echo '<select id="otto_wp_rocket_compat" name="' . $option_key . '[general][otto_wp_rocket_compat]">';
745 - echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
746 - echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>';
747 - echo '<option value="http"' . selected($value, 'http', false) . '>HTTP Mode (Safer)</option>';
748 - echo '<option value="disable_otto"' . selected($value, 'disable_otto', false) . '>Disable ' . esc_html($whitelabel_otto_name) . ' when WP Rocket is active</option>';
749 - echo '</select>';
750 -
751 - if ($wp_rocket_active) {
752 - echo '<p class="description" style="color: #0073aa; margin-top: 8px;">✓ WP Rocket detected - compatibility mode active</p>';
753 - }
754 -
755 - echo '<p class="description" style="margin-top: 8px;">';
756 - 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>';
757 - echo '<strong>Buffer Mode:</strong> Forces output buffering method. Fastest but may conflict with some configurations.<br>';
758 - echo '<strong>HTTP Mode:</strong> Forces internal HTTP fetch method. Slower but more compatible.<br>';
759 - 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.';
760 - echo '</p>';
778 + echo '<div class="metasync-seo-priority-control">';
779 + printf(
780 + '<label for="seo_priority"><input type="checkbox" id="seo_priority" name="%s" value="otto" %s /> <strong>Prioritize approved %s values</strong></label>',
781 + esc_attr($name),
782 + esc_attr($checked),
783 + esc_html($whitelabel_otto_name)
784 + );
785 + printf(
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>',
787 + esc_html($whitelabel_otto_name)
788 + );
789 + echo '</div>';
761 790 },
762 791 $page_slug . '_general',
763 792 $SECTION_METASYNC
764 793 );
765 794
766 - # SiteGround Optimizer compatibility mode setting
767 - add_settings_field(
768 - 'otto_sg_optimizer_compat',
769 - 'SiteGround Optimizer Compatibility',
770 - function() use ($option_key) {
771 - $value = Metasync::get_option('general')['otto_sg_optimizer_compat'] ?? 'auto';
772 - $sg_optimizer_active = defined('SiteGround_Optimizer\VERSION') || class_exists('SiteGround_Optimizer\Parser\Parser');
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';
773 804
774 - echo '<select id="otto_sg_optimizer_compat" name="' . $option_key . '[general][otto_sg_optimizer_compat]">';
775 - echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
776 - echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>';
777 - echo '<option value="http"' . selected($value, 'http', false) . '>HTTP Mode (Safer)</option>';
778 - echo '</select>';
805 + add_settings_section(
806 + 'metasync_compat_toggles',
807 + '',
808 + function() {},
809 + $compat_page_slug
810 + );
779 811
780 - if ($sg_optimizer_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 +
830 + echo '<p class="description" style="color: #0073aa; margin-top: 8px;">✓ WP Rocket detected - compatibility mode active</p>';
831 +
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 + }
843 +
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';
852 +
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>';
857 +
781 858 echo '<p class="description" style="color: #0073aa; margin-top: 8px;">✓ SiteGround Optimizer detected - compatibility mode active</p>';
782 - }
783 859
784 - echo '<p class="description" style="margin-top: 8px;">';
785 - 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>';
786 - 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>';
787 - echo '<strong>HTTP Mode:</strong> Forces the internal HTTP fetch method. Slower but more compatible.';
788 - echo '</p>';
789 - },
790 - $page_slug . '_general',
791 - $SECTION_METASYNC
792 - );
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 + }
793 869
794 - # BookingPress front-end session compatibility setting.
870 + # BookingPress front-end session compatibility toggle.
795 871 # Only registered when BookingPress is actually present on the site:
796 872 # the shim no-ops without it, so the field would be noise for every
797 - # other install. The stored value survives while the field is hidden
798 - # (the save loop is isset-guarded and keeps absent keys).
873 + # other install.
799 874 if (class_exists('bookingpress_spam_protection')) {
800 875 add_settings_field(
801 876 'bookingpress_session_compat',
802 877 'BookingPress Session Compatibility',
@@ -802,9 +877,9 @@
802 877 'BookingPress Session Compatibility',
803 878 function() use ($option_key) {
804 879 $value = Metasync::get_option('general')['bookingpress_session_compat'] ?? 'auto';
805 880
806 - 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]">';
807 882 echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
808 883 echo '<option value="off"' . selected($value, 'off', false) . '>Disabled</option>';
809 884 echo '</select>';
810 885
@@ -815,10 +890,10 @@
815 890 echo '<strong>Auto (Recommended):</strong> Removes the unnecessary session start on public pages so page caching can work.<br>';
816 891 echo '<strong>Disabled:</strong> Leaves BookingPress exactly as installed (no page caching while it stays active).';
817 892 echo '</p>';
818 893 },
819 - $page_slug . '_general',
820 - $SECTION_METASYNC
894 + $compat_page_slug,
895 + 'metasync_compat_toggles'
821 896 );
822 897 }
823 898
824 899 /**
@@ -832,9 +907,9 @@
832 907 'Disable ' . $whitelabel_otto_name . ' for Bot Traffic',
833 908 function() use ($whitelabel_otto_name, $option_key) {
834 909 $otto_disable_for_bots = Metasync::get_option('general')['otto_disable_for_bots'] ?? false;
835 910 printf(
836 - '<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 />',
837 912 filter_var($otto_disable_for_bots, FILTER_VALIDATE_BOOLEAN) ? 'checked' : ''
838 913 );
839 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));
840 915 },
@@ -848,9 +923,9 @@
848 923 'Bot Whitelist',
849 924 function() use ($whitelabel_otto_name, $option_key) {
850 925 $otto_bot_whitelist = Metasync::get_option('general')['otto_bot_whitelist'] ?? '';
851 926 printf(
852 - '<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>',
853 928 esc_textarea($otto_bot_whitelist)
854 929 );
855 930 echo '<p class="description">';
856 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>';
@@ -869,9 +944,9 @@
869 944 'Bot Blacklist',
870 945 function() use ($whitelabel_otto_name, $option_key) {
871 946 $otto_bot_blacklist = Metasync::get_option('general')['otto_bot_blacklist'] ?? '';
872 947 printf(
873 - '<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>',
874 949 esc_textarea($otto_bot_blacklist)
875 950 );
876 951 echo '<p class="description">';
877 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>';
@@ -893,9 +968,9 @@
893 968 printf(
894 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>',
895 970 esc_url(admin_url('admin.php?page=' . $page_slug . '-bot-statistics'))
896 971 );
897 - 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>';
898 973 },
899 974 $page_slug . '_general',
900 975 $SECTION_METASYNC
901 976 );
@@ -906,22 +981,20 @@
906 981 'periodic_clear_ottopage_cache',
907 982 'Clear Page Cache',
908 983 function() use ($option_key) {
909 984 $periodic_clear_ottopage_cache = Metasync::get_option('general')['periodic_clear_ottopage_cache'] ?? 'default';
910 - printf('<select style = "width : 250px" name="' . $option_key . '[general][periodic_clear_ottopage_cache]" id="heading_style">');
911 - printf('<option value="24" '. selected($periodic_clear_ottopage_cache, '24', false) . '>Clear Daily</option>');
912 - printf('<option value="36" '. selected($periodic_clear_ottopage_cache, '36', false) . '>Clear Every 2 days</option>');
913 - printf('<option value="40" '. selected($periodic_clear_ottopage_cache, '40', false) . '>Clear Weekly</option>');
914 - printf('<option value="0"'.selected($periodic_clear_ottopage_cache, '0', false).'>Clear Monthly (Default)</option>');
915 - 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>';
916 991
917 992 # get last cleared timestamp
918 993 $timestamp = get_option('metasync_refresh_all_caches')['pages'] ?? false;
919 994
920 995 if($timestamp > 0){
921 - printf(
922 - '<p class="descriptionValue">last Cleared : '.date('y-m-d H:i:s', $timestamp).'</p>'
923 - );
996 + echo '<p class="descriptionValue">last Cleared : '.esc_html(gmdate('y-m-d H:i:s', $timestamp)).'</p>';
924 997 }
925 998
926 999 },
927 1000 $page_slug . '_otto_cache',
@@ -932,22 +1005,20 @@
932 1005 'periodic_clear_ottopost_cache',
933 1006 'Clear Post Cache',
934 1007 function() use ($option_key) {
935 1008 $periodic_clear_ottopost_cache = Metasync::get_option('general')['periodic_clear_ottopost_cache'] ?? 'default';
936 - printf('<select style = "width : 250px" name="' . $option_key . '[general][periodic_clear_ottopost_cache]" id="heading_style">');
937 - printf('<option value="24" '. selected($periodic_clear_ottopost_cache, '24', false) . '>Clear Daily</option>');
938 - printf('<option value="36" '. selected($periodic_clear_ottopost_cache, '36', false) . '>Clear Every 2 days</option>');
939 - printf('<option value="40" '. selected($periodic_clear_ottopost_cache, '40', false) . '>Clear Weekly</option>');
940 - printf('<option value="0"'.selected($periodic_clear_ottopost_cache, '0', false).'>Clear Monthly (Default)</option>');
941 - 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>';
942 1015
943 1016 # get last cleared timestamp
944 1017 $timestamp = get_option('metasync_refresh_all_caches')['posts'] ?? false;
945 1018
946 1019 if($timestamp > 0){
947 - printf(
948 - '<p class="descriptionValue">last Cleared : '.date('y-m-d H:i:s', $timestamp).'</p>'
949 - );
1020 + echo '<p class="descriptionValue">last Cleared : '.esc_html(gmdate('y-m-d H:i:s', $timestamp)).'</p>';
950 1021 }
951 1022 },
952 1023 $page_slug . '_otto_cache',
953 1024 $SECTION_METASYNC
@@ -957,22 +1028,20 @@
957 1028 'periodic_clear_otto_cache',
958 1029 'Clear all cache',
959 1030 function() use ($option_key) {
960 1031 $periodic_clear_otto_cache = Metasync::get_option('general')['periodic_clear_otto_cache'] ?? 'default';
961 - printf('<select style = "width : 250px" name="' . $option_key . '[general][periodic_clear_otto_cache]" id="heading_style">');
962 - printf('<option value="24" '. selected($periodic_clear_otto_cache, '24', false) . '>Clear Daily</option>');
963 - printf('<option value="36" '. selected($periodic_clear_otto_cache, '36', false) . '>Clear Every 2 days</option>');
964 - printf('<option value="40" '. selected($periodic_clear_otto_cache, '40', false) . '>Clear Weekly</option>');
965 - printf('<option value="0"'.selected($periodic_clear_otto_cache, '0', false).'>Clear Monthly (Default)</option>');
966 - 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>';
967 1038
968 1039 # get last cleared timestamp
969 1040 $timestamp = get_option('metasync_refresh_all_caches')['general'] ?? false;
970 1041
971 1042 if($timestamp > 0){
972 - printf(
973 - '<p class="descriptionValue">last Cleared : '.date('y-m-d H:i:s', $timestamp).'</p>'
974 - );
1043 + echo '<p class="descriptionValue">last Cleared : '.esc_html(gmdate('y-m-d H:i:s', $timestamp)).'</p>';
975 1044 }
976 1045
977 1046 },
978 1047 $page_slug . '_otto_cache',
@@ -987,12 +1056,12 @@
987 1056 'Disable Common Robots Meta Box',
988 1057 function() use ($option_key) {
989 1058 $disabled = Metasync::get_option('general')['disable_common_robots_metabox'] ?? false;
990 1059 printf(
991 - '<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 />',
992 1061 $disabled ? 'checked' : ''
993 1062 );
994 - 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')));
995 1064 },
996 1065 $page_slug . '_general',
997 1066 $SECTION_METASYNC
998 1067 );
@@ -1002,12 +1071,12 @@
1002 1071 'Disable Advance Robots Meta Box',
1003 1072 function() use ($option_key) {
1004 1073 $disabled = Metasync::get_option('general')['disable_advance_robots_metabox'] ?? false;
1005 1074 printf(
1006 - '<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 />',
1007 1076 $disabled ? 'checked' : ''
1008 1077 );
1009 - 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')));
1010 1079 },
1011 1080 $page_slug . '_general',
1012 1081 $SECTION_METASYNC
1013 1082 );
@@ -1017,12 +1086,12 @@
1017 1086 'Disable Redirection Meta Box',
1018 1087 function() use ($option_key) {
1019 1088 $disabled = Metasync::get_option('general')['disable_redirection_metabox'] ?? false;
1020 1089 printf(
1021 - '<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 />',
1022 1091 $disabled ? 'checked' : ''
1023 1092 );
1024 - 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>';
1025 1094 },
1026 1095 $page_slug . '_general',
1027 1096 $SECTION_METASYNC
1028 1097 );
@@ -1032,12 +1101,12 @@
1032 1101 'Disable Canonical Meta Box',
1033 1102 function() use ($option_key) {
1034 1103 $disabled = Metasync::get_option('general')['disable_canonical_metabox'] ?? false;
1035 1104 printf(
1036 - '<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 />',
1037 1106 $disabled ? 'checked' : ''
1038 1107 );
1039 - 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')));
1040 1109 },
1041 1110 $page_slug . '_general',
1042 1111 $SECTION_METASYNC
1043 1112 );
@@ -1047,12 +1116,12 @@
1047 1116 'Disable Social Media & Open Graph Meta Box',
1048 1117 function() use ($option_key) {
1049 1118 $disabled = Metasync::get_option('general')['disable_social_opengraph_metabox'] ?? false;
1050 1119 printf(
1051 - '<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 />',
1052 1121 $disabled ? 'checked' : ''
1053 1122 );
1054 - 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')));
1055 1124 },
1056 1125 $page_slug . '_general',
1057 1126 $SECTION_METASYNC
1058 1127 );
@@ -1062,12 +1131,12 @@
1062 1131 'Disable Schema Markup Meta Box',
1063 1132 function() use ($option_key) {
1064 1133 $disabled = Metasync::get_option('general')['disable_schema_markup_metabox'] ?? false;
1065 1134 printf(
1066 - '<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 />',
1067 1136 $disabled ? 'checked' : ''
1068 1137 );
1069 - 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')));
1070 1139 },
1071 1140 $page_slug . '_general',
1072 1141 $SECTION_METASYNC
1073 1142 );
@@ -1072,17 +1141,32 @@
1072 1141 $SECTION_METASYNC
1073 1142 );
1074 1143
1075 1144 add_settings_field(
1145 + 'disable_language_alternates_metabox',
1146 + 'Disable Language Alternates Meta Box',
1147 + function() use ($option_key) {
1148 + $disabled = Metasync::get_option('general')['disable_language_alternates_metabox'] ?? false;
1149 + printf(
1150 + '<input type="checkbox" id="disable_language_alternates_metabox" name="' . esc_attr($option_key) . '[general][disable_language_alternates_metabox]" value="1" %s />',
1151 + $disabled ? 'checked' : ''
1152 + );
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;');
1154 + },
1155 + $page_slug . '_general',
1156 + $SECTION_METASYNC
1157 + );
1158 +
1159 + add_settings_field(
1076 1160 'disable_seo_metabox',
1077 1161 'Disable SEO Meta Box',
1078 1162 function() use ($option_key) {
1079 1163 $disabled = Metasync::get_option('general')['disable_seo_metabox'] ?? false;
1080 1164 printf(
1081 - '<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 />',
1082 1166 $disabled ? 'checked' : ''
1083 1167 );
1084 - 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>';
1085 1169 },
1086 1170 $page_slug . '_general',
1087 1171 $SECTION_METASYNC
1088 1172 );
@@ -1092,9 +1176,9 @@
1092 1176 'Open External Links in New Tab/Window',
1093 1177 function() use ($option_key) {
1094 1178 $enabled = Metasync::get_option('general')['open_external_links'] ?? false;
1095 1179 printf(
1096 - '<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 />',
1097 1181 $enabled ? 'checked' : ''
1098 1182 );
1099 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()));
1100 1184 },
@@ -1110,11 +1194,11 @@
1110 1194 $current_permalink_structure = get_option('permalink_structure');
1111 1195 $current_rewrite_rules = get_option('rewrite_rules');
1112 1196 // Check if the current permalink structure is set to "Plain"
1113 1197 if (($current_permalink_structure == '/%post_id%/' || $current_permalink_structure == '') && $current_rewrite_rules == '') {
1114 - 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>';
1115 1199 } else {
1116 - 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>';
1117 1201 }
1118 1202 },
1119 1203 $page_slug . '_general',
1120 1204 $SECTION_METASYNC
@@ -1126,9 +1210,9 @@
1126 1210 'Hide Dashboard',
1127 1211 function() use ($option_key) {
1128 1212 $hide_dashboard = Metasync::get_option('general')['hide_dashboard_framework'] ?? '';
1129 1213 printf(
1130 - '<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 />',
1131 1215 isset($hide_dashboard) && $hide_dashboard == 'true' ? 'checked' : ''
1132 1216 );
1133 1217 printf('<span class="description"> Hide the %s dashboard</span>', esc_html(Metasync_Settings_Registration::instance()->get_effective_menu_title()));
1134 1218 },
@@ -1144,9 +1228,9 @@
1144 1228 'Show ' . $this->get_effective_menu_title() . ' Status in Admin Bar',
1145 1229 function() use ($option_key) {
1146 1230 $show_admin_bar = Metasync::get_option('general')['show_admin_bar_status'] ?? true;
1147 1231 printf(
1148 - '<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 />',
1149 1233 $show_admin_bar ? 'checked' : ''
1150 1234 );
1151 1235 printf('<span class="description">Show the %s status indicator in the WordPress admin bar.</span>', esc_html(Metasync::get_effective_plugin_name()));
1152 1236 },
@@ -1153,24 +1237,8 @@
1153 1237 $page_slug . '_general',
1154 1238 $SECTION_METASYNC
1155 1239 );
1156 1240
1157 - # Adding the "Enable Auto-updates" setting
1158 - add_settings_field(
1159 - 'enable_auto_updates',
1160 - 'Enable Automatic Updates',
1161 - function() use ($option_key) {
1162 - $enable_auto_updates = Metasync::get_option('general')['enable_auto_updates'] ?? false;
1163 - printf(
1164 - '<input type="checkbox" id="enable_auto_updates" name="' . $option_key . '[general][enable_auto_updates]" value="true" %s />',
1165 - $enable_auto_updates ? 'checked' : ''
1166 - );
1167 - printf('<span class="description">Allow WordPress to automatically update this plugin when new versions are available.</span>');
1168 - },
1169 - $page_slug . '_general',
1170 - $SECTION_METASYNC
1171 - );
1172 -
1173 1241 # Add User Role Sync Setting
1174 1242 add_settings_field(
1175 1243 'content_genius_sync_roles',
1176 1244 'Content Genius User Roles to Sync',
@@ -1272,9 +1340,9 @@
1272 1340 echo '<select id="default_page_builder" name="' . esc_attr($option_key) . '[general][default_page_builder]" style="min-width: 300px;">';
1273 1341
1274 1342 foreach ($builders as $key => $builder) {
1275 1343 $is_detected = $builder['detected'];
1276 - $label = esc_html($builder['label']);
1344 + $label = $builder['label'];
1277 1345 if ($is_detected && $key !== 'gutenberg') {
1278 1346 $label .= ' — Detected';
1279 1347 } elseif (!$is_detected) {
1280 1348 $label .= ' — Not Detected';
@@ -1286,9 +1354,9 @@
1286 1354 '<option value="%s" %s%s>%s</option>',
1287 1355 esc_attr($key),
1288 1356 selected(empty($current) ? 'gutenberg' : $current, $key, false),
1289 1357 $is_detected ? '' : ' disabled',
1290 - $label
1358 + esc_html($label)
1291 1359 );
1292 1360 }
1293 1361
1294 1362 echo '</select>';
@@ -1337,9 +1405,9 @@
1337 1405 printf(
1338 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>',
1339 1407 esc_url(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-import-external'))
1340 1408 );
1341 - 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>';
1342 1410 },
1343 1411 $page_slug . '_general',
1344 1412 $SECTION_METASYNC
1345 1413 );
@@ -1354,10 +1422,10 @@
1354 1422 'white_label_plugin_name',
1355 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).'),
1356 1424 function() use ($option_key) {
1357 1425 $value = Metasync::get_option('general')['white_label_plugin_name'] ?? '';
1358 - printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_name]" value="' . esc_attr($value) . '" maxlength="18" />');
1359 - 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>';
1360 1428 },
1361 1429 $page_slug . '_branding',
1362 1430 $SECTION_METASYNC
1363 1431 );
@@ -1366,9 +1434,9 @@
1366 1434 'whitelabel_otto_name',
1367 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.'),
1368 1436 function() use ($option_key) {
1369 1437 $value = Metasync::get_option('general')['whitelabel_otto_name'] ?? '';
1370 - 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) . '" />';
1371 1439 $example_name = !empty($value) ? $value : 'OTTO';
1372 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));
1373 1441 },
1374 1442 $page_slug . '_branding',
@@ -1380,9 +1448,9 @@
1380 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.'),
1381 1449 function() use ($option_key) {
1382 1450 $whitelabel_settings = Metasync::get_whitelabel_settings();
1383 1451 $value = $whitelabel_settings['logo_light'] ?? '';
1384 - 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" />';
1385 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()));
1386 1454 },
1387 1455 $page_slug . '_branding',
1388 1456 $SECTION_METASYNC
@@ -1393,9 +1461,9 @@
1393 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.'),
1394 1462 function() use ($option_key) {
1395 1463 $whitelabel_settings = Metasync::get_whitelabel_settings();
1396 1464 $value = $whitelabel_settings['logo_dark'] ?? '';
1397 - 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" />';
1398 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()));
1399 1467 },
1400 1468 $page_slug . '_branding',
1401 1469 $SECTION_METASYNC
@@ -1406,10 +1474,10 @@
1406 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.'),
1407 1475 function() use ($option_key) {
1408 1476 $whitelabel_settings = Metasync::get_whitelabel_settings();
1409 1477 $value = $whitelabel_settings['domain'] ?? '';
1410 - printf('<input type="url" name="' . $option_key . '[whitelabel][domain]" value="' . esc_attr($value) . '" size="60" />');
1411 - 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>';
1412 1480 },
1413 1481 $page_slug . '_branding',
1414 1482 $SECTION_METASYNC
1415 1483 );
@@ -1417,9 +1485,9 @@
1417 1485 'white_label_plugin_description',
1418 1486 'Plugin Description',
1419 1487 function() use ($option_key) {
1420 1488 $value = Metasync::get_option('general')['white_label_plugin_description'] ?? '';
1421 - 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) . '" />';
1422 1490 },
1423 1491 $page_slug . '_branding',
1424 1492 $SECTION_METASYNC
1425 1493 );
@@ -1428,9 +1496,9 @@
1428 1496 'white_label_plugin_author',
1429 1497 'Author',
1430 1498 function() use ($option_key) {
1431 1499 $value = Metasync::get_option('general')['white_label_plugin_author'] ?? '';
1432 - 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) . '" />';
1433 1501 },
1434 1502 $page_slug . '_branding',
1435 1503 $SECTION_METASYNC
1436 1504 );
@@ -1439,9 +1507,9 @@
1439 1507 'white_label_plugin_author_uri',
1440 1508 'Author URI',
1441 1509 function() use ($option_key) {
1442 1510 $value = Metasync::get_option('general')['white_label_plugin_author_uri'] ?? '';
1443 - # 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) . '" />');
1444 1512 # Fixed printf usage
1445 1513 printf('<input type="text" name="%s" value="%s" />', esc_attr($option_key . '[general][white_label_plugin_author_uri]'), esc_attr($value) );
1446 1514
1447 1515 },
@@ -1452,9 +1520,9 @@
1452 1520 'white_label_plugin_uri',
1453 1521 'Plugin URI',
1454 1522 function() use ($option_key) {
1455 1523 $value = Metasync::get_option('general')['white_label_plugin_uri'] ?? ''; // New option for Plugin URI
1456 - 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) . '" />';
1457 1525 },
1458 1526 $page_slug . '_branding',
1459 1527 $SECTION_METASYNC
1460 1528 );
@@ -1468,10 +1536,10 @@
1468 1536 'white_label_plugin_menu_slug',
1469 1537 'Menu Slug',
1470 1538 function() use ($option_key) {
1471 1539 $value = Metasync::get_option('general')['white_label_plugin_menu_slug'] ?? '';
1472 - printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_menu_slug]" value="' . esc_attr($value) . '" placeholder="hip-advanced-seo" />');
1473 - 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>';
1474 1542 },
1475 1543 $page_slug . '_branding',
1476 1544 $SECTION_METASYNC
1477 1545 );
@@ -1479,9 +1547,9 @@
1479 1547 'white_label_plugin_menu_icon',
1480 1548 'Menu Icon',
1481 1549 function() use ($option_key) {
1482 1550 $value = Metasync::get_option('general')['white_label_plugin_menu_icon'] ?? '';
1483 - 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) . '" />';
1484 1552 },
1485 1553 $page_slug . '_branding',
1486 1554 $SECTION_METASYNC
1487 1555 );
@@ -1496,22 +1564,22 @@
1496 1564
1497 1565 // Output radio button for Default Style.css active
1498 1566
1499 1567 printf(
1500 - '<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 />',
1501 1569 ($enabled_plugin_css == 'default'||$enabled_plugin_css =='') ? 'checked' : ''
1502 1570 );
1503 - printf('<label for="enable_default">Default</label><br>');
1571 + echo '<label for="enable_default">Default</label><br>';
1504 1572
1505 1573
1506 1574 // Output radio button for Metasync Style
1507 1575 printf(
1508 - '<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 />',
1509 1577 ($enabled_plugin_css == 'metasync') ? 'checked' : ''
1510 1578 );
1511 - printf('<label for="enable_metasync">Metasync Style</label>');
1579 + echo '<label for="enable_metasync">Metasync Style</label>';
1512 1580
1513 - 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>';
1514 1582 },
1515 1583 $page_slug . '_branding',
1516 1584 $SECTION_METASYNC
1517 1585 );
@@ -1521,12 +1589,12 @@
1521 1589 'enabled_elementor_plugin_css',
1522 1590 'Choose Elementor Font Style',
1523 1591 function() use ($option_key) {
1524 1592 $enabled_elementor_plugin_css = Metasync::get_option('general')['enabled_elementor_plugin_css'] ?? 'default';
1525 - printf('<select name="' . $option_key . '[general][enabled_elementor_plugin_css]" id="heading_style">');
1526 - printf('<option value="default"'.selected($enabled_elementor_plugin_css, 'default', false).'>Default</option>');
1527 - printf('<option value="custom" '. selected($enabled_elementor_plugin_css, 'custom', false) . '>Custom</option>');
1528 - 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>';
1529 1597 },
1530 1598 $page_slug . '_branding',
1531 1599 $SECTION_METASYNC
1532 1600 );
@@ -1534,9 +1602,9 @@
1534 1602 'enabled_elementor_plugin_css_color',
1535 1603 'Choose Elementor Font Color',
1536 1604 function() use ($option_key) {
1537 1605 $enabled_elementor_plugin_css_color = Metasync::get_option('general')['enabled_elementor_plugin_css_color'] ?? '#000000';
1538 - 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).'">';
1539 1607 },
1540 1608 $page_slug . '_branding',
1541 1609 $SECTION_METASYNC
1542 1610 );
@@ -1547,10 +1615,10 @@
1547 1615 function() use ($option_key) {
1548 1616 // Decrypted for display — this field only renders after the
1549 1617 // whitelabel password gate has been passed.
1550 1618 $value = Metasync::get_whitelabel_password();
1551 - printf('<input type="password" name="' . $option_key . '[whitelabel][settings_password]" value="' . esc_attr($value) . '" size="30" autocomplete="new-password" />');
1552 - 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>';
1553 1621 },
1554 1622 $page_slug . '_branding',
1555 1623 $SECTION_METASYNC
1556 1624 );
@@ -1561,10 +1629,10 @@
1561 1629 function() use ($option_key) {
1562 1630 $whitelabel_settings = Metasync::get_whitelabel_settings();
1563 1631 $value = $whitelabel_settings['recovery_email'] ?? '';
1564 1632 $has_password = !empty($whitelabel_settings['settings_password']);
1565 - printf('<input type="email" name="' . $option_key . '[whitelabel][recovery_email]" value="' . esc_attr($value) . '" size="30" autocomplete="email" %s />', $has_password ? 'required' : '');
1566 - 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>';
1567 1635 },
1568 1636 $page_slug . '_branding',
1569 1637 $SECTION_METASYNC
1570 1638 );
@@ -1594,11 +1662,11 @@
1594 1662 <td><input type="text" name="%s[whitelabel][quick_links][%d][label]" value="%s" placeholder="Link label" style="width:100%%" /></td>
1595 1663 <td><input type="url" name="%s[whitelabel][quick_links][%d][url]" value="%s" placeholder="https://…" style="width:100%%" /></td>
1596 1664 <td style="text-align:center"><input type="checkbox" name="%s[whitelabel][quick_links][%d][external]" value="1" %s /></td>
1597 1665 </tr>',
1598 - esc_attr($option_key), $i, $label,
1599 - esc_attr($option_key), $i, $url,
1600 - 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)
1601 1669 );
1602 1670 }
1603 1671 echo '</tbody></table>';
1604 1672 },
@@ -1625,9 +1693,9 @@
1625 1693 'Organization Name',
1626 1694 function() use ($option_key) {
1627 1695 $schema = Metasync::get_option('schema') ?? [];
1628 1696 $value = $schema['org_name'] ?? '';
1629 - 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));
1630 1698 },
1631 1699 $schema_page,
1632 1700 'metasync_schema_org'
1633 1701 );
@@ -1637,9 +1705,9 @@
1637 1705 'Organization URL',
1638 1706 function() use ($option_key) {
1639 1707 $schema = Metasync::get_option('schema') ?? [];
1640 1708 $value = $schema['org_url'] ?? '';
1641 - 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));
1642 1710 },
1643 1711 $schema_page,
1644 1712 'metasync_schema_org'
1645 1713 );
@@ -1649,9 +1717,9 @@
1649 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.'),
1650 1718 function() use ($option_key) {
1651 1719 $schema = Metasync::get_option('schema') ?? [];
1652 1720 $value = $schema['org_logo'] ?? '';
1653 - 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));
1654 1722 },
1655 1723 $schema_page,
1656 1724 'metasync_schema_org'
1657 1725 );
@@ -1661,9 +1729,9 @@
1661 1729 'Contact Telephone',
1662 1730 function() use ($option_key) {
1663 1731 $schema = Metasync::get_option('schema') ?? [];
1664 1732 $value = $schema['org_contact_telephone'] ?? '';
1665 - 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));
1666 1734 },
1667 1735 $schema_page,
1668 1736 'metasync_schema_org'
1669 1737 );
@@ -1673,9 +1741,9 @@
1673 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.'),
1674 1742 function() use ($option_key) {
1675 1743 $schema = Metasync::get_option('schema') ?? [];
1676 1744 $value = $schema['org_contact_type'] ?? '';
1677 - 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));
1678 1746 },
1679 1747 $schema_page,
1680 1748 'metasync_schema_org'
1681 1749 );
@@ -1685,9 +1753,9 @@
1685 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.'),
1686 1754 function() use ($option_key) {
1687 1755 $schema = Metasync::get_option('schema') ?? [];
1688 1756 $value = $schema['org_same_as'] ?? '';
1689 - 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));
1690 1758 echo '<p class="description">Enter one social profile URL per line.</p>';
1691 1759 },
1692 1760 $schema_page,
1693 1761 'metasync_schema_org'
@@ -1707,9 +1775,9 @@
1707 1775 'WebSite Name',
1708 1776 function() use ($option_key) {
1709 1777 $schema = Metasync::get_option('schema') ?? [];
1710 1778 $value = $schema['website_name'] ?? '';
1711 - 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));
1712 1780 },
1713 1781 $schema_page,
1714 1782 'metasync_schema_website'
1715 1783 );
@@ -1719,9 +1787,9 @@
1719 1787 'WebSite URL',
1720 1788 function() use ($option_key) {
1721 1789 $schema = Metasync::get_option('schema') ?? [];
1722 1790 $value = $schema['website_url'] ?? '';
1723 - 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));
1724 1792 },
1725 1793 $schema_page,
1726 1794 'metasync_schema_website'
1727 1795 );
@@ -1732,9 +1800,9 @@
1732 1800 function() use ($option_key) {
1733 1801 $schema = Metasync::get_option('schema') ?? [];
1734 1802 $value = $schema['website_searchbox'] ?? false;
1735 1803 printf(
1736 - '<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 />',
1737 1805 checked($value, true, false)
1738 1806 );
1739 1807 echo '<span class="description">Adds SearchAction to WebSite schema for Google Sitelinks Searchbox.</span>';
1740 1808 },
@@ -1757,9 +1825,9 @@
1757 1825 function() use ($option_key) {
1758 1826 $schema = Metasync::get_option('schema') ?? [];
1759 1827 $value = $schema['override_woocommerce_schema'] ?? false;
1760 1828 printf(
1761 - '<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 />',
1762 1830 checked($value, true, false)
1763 1831 );
1764 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>';
1765 1833 },
@@ -1813,9 +1881,9 @@
1813 1881 'baidu_site_verification',
1814 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.'),
1815 1883 function() use ($option_key) {
1816 1884 $value = Metasync::get_option('searchengines')['baidu_site_verification'] ?? '';
1817 - 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 ) );
1818 1886 },
1819 1887 $page_slug . '_searchengines-verification',
1820 1888 $SECTION_SEARCHENGINE
1821 1889 );
@@ -1824,9 +1892,9 @@
1824 1892 'alexa_site_verification',
1825 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.'),
1826 1894 function() use ($option_key) {
1827 1895 $value = Metasync::get_option('searchengines')['alexa_site_verification'] ?? '';
1828 - 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 ) );
1829 1897 },
1830 1898 $page_slug . '_searchengines-verification',
1831 1899 $SECTION_SEARCHENGINE
1832 1900 );
@@ -1835,9 +1903,9 @@
1835 1903 'norton_save_site_verification',
1836 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.'),
1837 1905 function() use ($option_key) {
1838 1906 $value = Metasync::get_option('searchengines')['norton_save_site_verification'] ?? '';
1839 - 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 ) );
1840 1908 },
1841 1909 $page_slug . '_searchengines-verification',
1842 1910 $SECTION_SEARCHENGINE
1843 1911 );
@@ -1896,121 +1964,14 @@
1896 1964 add_settings_field( 'local_seo_map_key', 'Google Maps Key' . Metasync::get_tooltip_icon_html('local_seo_map_key_tooltip', 'A Google Maps API key from your Google Cloud account. Only needed if you want an embedded map; it lets Google bill and authorize the map requests.'), array( $fields_instance, 'local_seo_map_key_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1897 1965 add_settings_field( 'local_seo_geo_coordinates', 'Geo Coordinates' . Metasync::get_tooltip_icon_html('local_seo_geo_coordinates_tooltip', 'The exact latitude and longitude of your business. This helps search engines and maps pin your location precisely, especially when your street address is ambiguous.'), array( $fields_instance, 'local_seo_geo_coordinates_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1898 1966
1899 1967 // ----------------------------------------------------------------
1900 - // Breadcrumbs page
1968 + // Breadcrumbs page — intentionally registers no Settings API fields.
1969 + // The dedicated Breadcrumbs admin page renders the richer controls
1970 + // directly via Metasync_Settings_Fields::render_breadcrumbs_section()
1971 + // and writes the same metasync_options[breadcrumbs] keys.
1901 1972 // ----------------------------------------------------------------
1902 - add_settings_section(
1903 - $SECTION_BREADCRUMBS,
1904 - '',
1905 - function() {},
1906 - $page_slug . '_breadcrumbs'
1907 - );
1908 1973
1909 - add_settings_field(
1910 - 'breadcrumbs_enabled',
1911 - 'Enable Breadcrumbs',
1912 - function() use ($option_key) {
1913 - $value = Metasync::get_option('breadcrumbs')['enabled'] ?? true;
1914 - printf(
1915 - '<input type="checkbox" id="breadcrumbs_enabled" name="' . $option_key . '[breadcrumbs][enabled]" value="1" %s />',
1916 - checked(1, $value, false)
1917 - );
1918 - echo '<p class="description">Output breadcrumb trail HTML on your site.</p>';
1919 - },
1920 - $page_slug . '_breadcrumbs',
1921 - $SECTION_BREADCRUMBS
1922 - );
1923 -
1924 - add_settings_field(
1925 - 'breadcrumbs_separator',
1926 - 'Separator',
1927 - function() use ($option_key) {
1928 - $value = Metasync::get_option('breadcrumbs')['separator'] ?? '&raquo;';
1929 - printf(
1930 - '<input type="text" id="breadcrumbs_separator" name="' . $option_key . '[breadcrumbs][separator]" value="%s" size="10" />',
1931 - esc_attr($value)
1932 - );
1933 - echo '<p class="description">Character or HTML entity shown between crumbs (e.g. &raquo; or /).</p>';
1934 - },
1935 - $page_slug . '_breadcrumbs',
1936 - $SECTION_BREADCRUMBS
1937 - );
1938 -
1939 - add_settings_field(
1940 - 'breadcrumbs_home_label',
1941 - 'Home Label',
1942 - function() use ($option_key) {
1943 - $value = Metasync::get_option('breadcrumbs')['home_label'] ?? 'Home';
1944 - printf(
1945 - '<input type="text" id="breadcrumbs_home_label" name="' . $option_key . '[breadcrumbs][home_label]" value="%s" size="30" />',
1946 - esc_attr($value)
1947 - );
1948 - },
1949 - $page_slug . '_breadcrumbs',
1950 - $SECTION_BREADCRUMBS
1951 - );
1952 -
1953 - add_settings_field(
1954 - 'breadcrumbs_home_url',
1955 - 'Home URL',
1956 - function() use ($option_key) {
1957 - $value = Metasync::get_option('breadcrumbs')['home_url'] ?? '';
1958 - printf(
1959 - '<input type="url" id="breadcrumbs_home_url" name="' . $option_key . '[breadcrumbs][home_url]" value="%s" size="50" />',
1960 - esc_attr($value)
1961 - );
1962 - echo '<p class="description">Leave blank to use the site home URL.</p>';
1963 - },
1964 - $page_slug . '_breadcrumbs',
1965 - $SECTION_BREADCRUMBS
1966 - );
1967 -
1968 - add_settings_field(
1969 - 'breadcrumbs_show_current_page',
1970 - 'Show Current Page',
1971 - function() use ($option_key) {
1972 - $value = Metasync::get_option('breadcrumbs')['show_current_page'] ?? true;
1973 - printf(
1974 - '<input type="checkbox" id="breadcrumbs_show_current_page" name="' . $option_key . '[breadcrumbs][show_current_page]" value="1" %s />',
1975 - checked(1, $value, false)
1976 - );
1977 - echo '<p class="description">Include the current page as the last (non-linked) crumb.</p>';
1978 - },
1979 - $page_slug . '_breadcrumbs',
1980 - $SECTION_BREADCRUMBS
1981 - );
1982 -
1983 - add_settings_field(
1984 - 'breadcrumbs_prefix_text',
1985 - 'Prefix Text',
1986 - function() use ($option_key) {
1987 - $value = Metasync::get_option('breadcrumbs')['prefix_text'] ?? '';
1988 - printf(
1989 - '<input type="text" id="breadcrumbs_prefix_text" name="' . $option_key . '[breadcrumbs][prefix_text]" value="%s" size="30" />',
1990 - esc_attr($value)
1991 - );
1992 - echo '<p class="description">Optional text before the breadcrumb trail (e.g. "You are here:").</p>';
1993 - },
1994 - $page_slug . '_breadcrumbs',
1995 - $SECTION_BREADCRUMBS
1996 - );
1997 -
1998 - add_settings_field(
1999 - 'breadcrumbs_archive_label_format',
2000 - 'Archive Label Format',
2001 - function() use ($option_key) {
2002 - $value = Metasync::get_option('breadcrumbs')['archive_label_format'] ?? '{name}';
2003 - printf(
2004 - '<input type="text" id="breadcrumbs_archive_label_format" name="' . $option_key . '[breadcrumbs][archive_label_format]" value="%s" size="30" />',
2005 - esc_attr($value)
2006 - );
2007 - echo '<p class="description">Format for archive crumb labels. Use <code>{name}</code> as placeholder for the archive name.</p>';
2008 - },
2009 - $page_slug . '_breadcrumbs',
2010 - $SECTION_BREADCRUMBS
2011 - );
2012 -
2013 1974 # Open Graph / Article meta toggles — surface each new tag family as its
2014 1975 # own opt-out checkbox on the main Settings page (Open Graph accordion).
2015 1976 add_settings_field(
2016 1977 'og_image_dimensions',
@@ -2110,8 +2071,71 @@
2110 2071 return $base;
2111 2072 }
2112 2073
2113 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 + /**
2114 2138 * Whether the array is a numerically-indexed list (keys 0..n-1).
2115 2139 *
2116 2140 * Answers false for the empty array — range(0, -1) counts downwards and
2117 2141 * yields [0, -1], not []. That is load-bearing rather than incidental: an
@@ -2160,10 +2184,10 @@
2160 2184 * @return array Sanitized settings
2161 2185 */
2162 2186 public function sanitize($input)
2163 2187 {
2164 - $submitted_generation = isset($_POST['metasync_pw_reset_generation'])
2165 - ? 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
2166 2190 : null;
2167 2191 // Legacy/stale forms that include a password but no reset generation
2168 2192 // must never be allowed to overwrite a newer recovery password.
2169 2193 if (!self::$allow_recovery_password_write
@@ -2207,15 +2231,15 @@
2207 2231
2208 2232 # Determine which tab is being submitted (same logic as AJAX handler)
2209 2233 # Check POST first (from AJAX), then GET (from form action URL)
2210 2234 $active_tab = 'general';
2211 - if (isset($_POST['active_tab'])) {
2212 - $active_tab = sanitize_text_field($_POST['active_tab']);
2213 - } elseif (isset($_GET['tab'])) {
2214 - $active_tab = sanitize_text_field($_GET['tab']);
2215 - } 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
2216 2240 # Parse referer URL to get tab parameter
2217 - $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
2218 2242 if (isset($referer['query'])) {
2219 2243 parse_str($referer['query'], $referer_params);
2220 2244 if (isset($referer_params['tab'])) {
2221 2245 $active_tab = sanitize_text_field($referer_params['tab']);
@@ -2222,8 +2246,9 @@
2222 2246 }
2223 2247 }
2224 2248 }
2225 2249 $general_tab_submitted = ($active_tab === 'general');
2250 + $compatibility_tab_submitted = ($active_tab === 'compatibility');
2226 2251
2227 2252 // General Settings
2228 2253 if (isset($input['general']['apikey'])) {
2229 2254 $new_input['general']['apikey'] = sanitize_text_field($input['general']['apikey']);
@@ -2228,8 +2253,26 @@
2228 2253 if (isset($input['general']['apikey'])) {
2229 2254 $new_input['general']['apikey'] = sanitize_text_field($input['general']['apikey']);
2230 2255 }
2231 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 +
2232 2275 // Meta Box Visibility Settings
2233 2276 if (isset($input['general']) && $general_tab_submitted) {
2234 2277 $checkbox_fields = [
2235 2278 'disable_common_robots_metabox',
@@ -2237,10 +2280,15 @@
2237 2280 'disable_redirection_metabox',
2238 2281 'disable_canonical_metabox',
2239 2282 'disable_social_opengraph_metabox',
2240 2283 'disable_schema_markup_metabox',
2284 + 'disable_language_alternates_metabox',
2241 2285 'disable_seo_metabox',
2242 - 'open_external_links'
2286 + 'open_external_links',
2287 + // Consent switch for writing into other SEO plugins. Absent from
2288 + // the POST means unchecked, which must resolve to off — a
2289 + // permanent write into another plugin's data is never inferred.
2290 + Metasync_Seo_Backup::CONSENT_OPTION_KEY
2243 2291 ];
2244 2292
2245 2293 foreach ($checkbox_fields as $field) {
2246 2294 if (isset($input['general'][$field])) {
@@ -2251,8 +2299,21 @@
2251 2299 // back through the layered merge.
2252 2300 $new_input['general'][$field] = false;
2253 2301 }
2254 2302 }
2303 +
2304 + // SEO priority is a checkbox whose stored value is a string, so it
2305 + // cannot ride the boolean loop above. Only the exact opt-in value
2306 + // is accepted; unchecked (absent) or anything else resolves to
2307 + // custom-first. Writing an explicit value rather than leaving the
2308 + // key absent matters for the same reason the loop above writes
2309 + // false: the layered merge would otherwise let the stored 'otto'
2310 + // leak back and the box could never be unticked.
2311 + $new_input['general']['seo_priority'] =
2312 + (isset($input['general']['seo_priority'])
2313 + && $input['general']['seo_priority'] === Metasync_Seo_Precedence::PRIORITY_OTTO)
2314 + ? Metasync_Seo_Precedence::PRIORITY_OTTO
2315 + : 'custom';
2255 2316 }
2256 2317
2257 2318 // Site Verification Settings
2258 2319 if (isset($input['searchengines']['bing_site_verification'])) {
@@ -2354,13 +2415,28 @@
2354 2415 }
2355 2416
2356 2417 // Breadcrumbs Settings
2357 2418 if (isset($input['breadcrumbs'])) {
2358 - $new_input['breadcrumbs']['enabled'] = !empty($input['breadcrumbs']['enabled']);
2359 - $new_input['breadcrumbs']['show_current_page'] = !empty($input['breadcrumbs']['show_current_page']);
2360 - $new_input['breadcrumbs']['disable_schema'] = !empty($input['breadcrumbs']['disable_schema']);
2419 + // Checkbox fields are only written when the submitting form
2420 + // actually rendered them. render_breadcrumbs_section() pairs every
2421 + // checkbox with a hidden companion input, so the key is always
2422 + // present there and unchecking works. A missing key means the form
2423 + // does not manage that field, so the stored value is preserved
2424 + // instead of being silently reset to false.
2425 + foreach (array('enabled', 'show_current_page', 'disable_schema') as $breadcrumb_flag) {
2426 + if (isset($input['breadcrumbs'][$breadcrumb_flag])) {
2427 + $new_input['breadcrumbs'][$breadcrumb_flag] = !empty($input['breadcrumbs'][$breadcrumb_flag]);
2428 + }
2429 + }
2430 + // The Custom radio always renders and can submit an empty value
2431 + // when selected without typing. An empty separator would render
2432 + // a blank gap between breadcrumb links, so it is dropped and the
2433 + // stored value survives, exactly like an absent key.
2361 2434 if (isset($input['breadcrumbs']['separator'])) {
2362 - $new_input['breadcrumbs']['separator'] = sanitize_text_field($input['breadcrumbs']['separator']);
2435 + $breadcrumb_separator = sanitize_text_field($input['breadcrumbs']['separator']);
2436 + if ('' !== $breadcrumb_separator) {
2437 + $new_input['breadcrumbs']['separator'] = $breadcrumb_separator;
2438 + }
2363 2439 }
2364 2440 if (isset($input['breadcrumbs']['home_label'])) {
2365 2441 $new_input['breadcrumbs']['home_label'] = sanitize_text_field($input['breadcrumbs']['home_label']);
2366 2442 }
@@ -2697,21 +2773,16 @@
2697 2773 }
2698 2774 }
2699 2775 }
2700 2776
2701 - // Indexation Control Settings
2702 - if (isset($input['seo_controls']['index_date_archives'])) {
2703 - $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 + }
2704 2784 }
2705 - if (isset($input['seo_controls']['index_tag_archives'])) {
2706 - $new_input['seo_controls']['index_tag_archives'] = boolval($input['seo_controls']['index_tag_archives']);
2707 - }
2708 - if (isset($input['seo_controls']['index_author_archives'])) {
2709 - $new_input['seo_controls']['index_author_archives'] = boolval($input['seo_controls']['index_author_archives']);
2710 - }
2711 - if (isset($input['seo_controls']['index_format_archives'])) {
2712 - $new_input['seo_controls']['index_format_archives'] = boolval($input['seo_controls']['index_format_archives']);
2713 - }
2714 2785
2715 2786 // Handle post-save heartbeat trigger for domain changes
2716 2787 if (isset($new_input['_trigger_heartbeat_after_save'])) {
2717 2788 $context = $new_input['_trigger_heartbeat_after_save'];
@@ -2722,8 +2793,26 @@
2722 2793 do_action('metasync_trigger_immediate_heartbeat', 'Whitelabel domain change - ' . $context);
2723 2794 });
2724 2795 }
2725 2796
2797 + // Headless settings are never written through this form. They are owned by
2798 + // Metasync_Headless_Config and saved by their own handler, so anything
2799 + // arriving here under that key is a stale form or a hand-built request.
2800 + // The merge below would let it write over the stored block key by key —
2801 + // switching the mode off that way removes the GraphQL field and breaks
2802 + // every persisted query a headless frontend holds, so the key is dropped
2803 + // rather than merged.
2804 + //
2805 + // Except when the owner is the one writing. register_setting() makes this
2806 + // method a `sanitize_option_metasync_options` filter, and WordPress runs
2807 + // it on every update_option() call for the option, not only on Settings
2808 + // API form posts — so dropping the key unconditionally also dropped
2809 + // Metasync_Headless_Config's own writes, and the Headless Mode block
2810 + // could never save anything on a real request.
2811 + if (!Metasync_Headless_Config::is_writing()) {
2812 + unset($input[Metasync_Headless_Config::SETTINGS_KEY]);
2813 + }
2814 +
2726 2815 // Keep the fully sanitized/encrypted white-label values. The layered
2727 2816 // merge below already lets them win; mirroring them into the raw
2728 2817 // layer also excludes stray raw whitelabel keys from the saved option.
2729 2818 if (isset($input['whitelabel'], $new_input['whitelabel'])) {
@@ -2729,13 +2818,71 @@
2729 2818 if (isset($input['whitelabel'], $new_input['whitelabel'])) {
2730 2819 $input['whitelabel'] = $new_input['whitelabel'];
2731 2820 }
2732 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 +
2733 2869 // Layered merge: stored option, then the raw submission, then the
2734 2870 // sanitized values. Replaces the former shallow array_merge(), which
2735 2871 // let the raw submission overwrite every sanitized section wholesale.
2736 2872 $result = self::merge_option_layers($stored_option, $input, $new_input);
2737 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 +
2738 2885 if ($whitelabel_reset) {
2739 2886 $result['whitelabel'] = $new_input['whitelabel'];
2740 2887 }
2741 2888
@@ -2754,9 +2901,10 @@
2754 2901 * Moved from Metasync_Admin – the admin class now delegates here.
2755 2902 */
2756 2903 public function meta_sync_save_settings()
2757 2904 {
2758 - 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')) {
2759 2907 wp_send_json_error(array('message' => 'Invalid nonce'));
2760 2908 return;
2761 2909 }
2762 2910
@@ -2793,9 +2941,12 @@
2793 2941 'white_label_plugin_name', 'white_label_plugin_description',
2794 2942 'white_label_plugin_author', 'white_label_plugin_menu_slug',
2795 2943 'white_label_plugin_menu_icon', 'enabled_plugin_css',
2796 2944 'enabled_elementor_plugin_css_color','enabled_elementor_plugin_css',
2797 - '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',
2798 2949 'periodic_clear_ottopost_cache', 'whitelabel_otto_name', 'otto_wp_rocket_compat',
2799 2950 'otto_sg_optimizer_compat',
2800 2951 'bookingpress_session_compat'
2801 2952 ];
@@ -2803,9 +2954,9 @@
2803 2954 $url_fields = [
2804 2955 'white_label_plugin_author_uri', 'white_label_plugin_uri'
2805 2956 ];
2806 2957
2807 - $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_seo_metabox', 'open_external_links'];
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];
2808 2959
2809 2960 $url_fields = ['white_label_plugin_author_uri', 'white_label_plugin_uri'];
2810 2961
2811 2962 $metasync_options = Metasync::get_option();
@@ -2817,9 +2968,9 @@
2817 2968 }
2818 2969
2819 2970 $validation_errors = [];
2820 2971
2821 - $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';
2822 2973 $general_tab_submitted = ($active_tab === 'general');
2823 2974 $whitelabel_tab_submitted = ($active_tab === 'whitelabel');
2824 2975
2825 2976 // Whitelabel field validation issues are collected per-field and reported back
@@ -2825,13 +2976,27 @@
2825 2976 // Whitelabel field validation issues are collected per-field and reported back
2826 2977 // without blocking the rest of the settings save.
2827 2978 $whitelabel_errors = [];
2828 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 +
2829 2993 foreach ($text_fields as $field) {
2830 2994
2831 2995 if (isset($_POST['metasync_options']['general'][$field])) {
2832 2996
2833 - $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]));
2834 2999
2835 3000 // searchatlas_api_key must be clearable to support key removal
2836 3001 $clearable_fields = [
2837 3002 'searchatlas_api_key',
@@ -2839,10 +3004,9 @@
2839 3004 'white_label_plugin_description',
2840 3005 'white_label_plugin_author',
2841 3006 'white_label_plugin_menu_slug',
2842 3007 'white_label_plugin_menu_icon',
2843 - 'whitelabel_otto_name',
2844 - 'otto_pixel_uuid'
3008 + 'whitelabel_otto_name'
2845 3009 ];
2846 3010
2847 3011 if ($value === '' && !in_array($field, $clearable_fields)) {
2848 3012 continue;
@@ -2857,8 +3021,25 @@
2857 3021 continue;
2858 3022 }
2859 3023 }
2860 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 +
2861 3042 if ($field === 'white_label_plugin_menu_icon') {
2862 3043
2863 3044 if ($value === '') {
2864 3045 $metasync_options['general'][$field] = '';
@@ -2868,9 +3049,9 @@
2868 3049 } elseif (filter_var($value, FILTER_VALIDATE_URL)) {
2869 3050
2870 3051 $image_extensions = ['png', 'svg'];
2871 3052
2872 - $path = parse_url($value, PHP_URL_PATH);
3053 + $path = wp_parse_url($value, PHP_URL_PATH);
2873 3054 $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
2874 3055
2875 3056 if (in_array($extension, $image_extensions, true)) {
2876 3057 $metasync_options['general'][$field] = esc_url_raw($value);
@@ -2884,9 +3065,9 @@
2884 3065 // Self-correcting slug: a URL-shaped value is reduced to a valid WP
2885 3066 // menu slug so it can never break sidebar link generation.
2886 3067 $metasync_options['general'][$field] = sanitize_title($value);
2887 3068 } else {
2888 - $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]));
2889 3070 }
2890 3071 }
2891 3072 }
2892 3073
@@ -2893,9 +3074,9 @@
2893 3074 $textarea_fields = ['otto_bot_whitelist', 'otto_bot_blacklist'];
2894 3075 if ($general_tab_submitted) {
2895 3076 foreach ($textarea_fields as $field) {
2896 3077 if (isset($_POST['metasync_options']['general'][$field])) {
2897 - $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]));
2898 3079 } else {
2899 3080 $metasync_options['general'][$field] = '';
2900 3081 }
2901 3082 }
@@ -2903,13 +3084,23 @@
2903 3084
2904 3085 if ($general_tab_submitted) {
2905 3086 foreach ($bool_fields as $field) {
2906 3087 if (isset($_POST['metasync_options']['general'][$field])) {
2907 - $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);
2908 3089 }else {
2909 3090 $metasync_options['general'][$field] = false;
2910 3091 }
2911 3092 }
3093 +
3094 + // SEO priority is a checkbox that stores a string rather than a
3095 + // bool, so it cannot ride the loop above. Only the exact opt-in
3096 + // value is stored; an unchecked box (or any tampered payload)
3097 + // falls back to custom-first, which is the pre-existing behaviour.
3098 + $metasync_options['general']['seo_priority'] =
3099 + (isset($_POST['metasync_options']['general']['seo_priority'])
3100 + && $_POST['metasync_options']['general']['seo_priority'] === Metasync_Seo_Precedence::PRIORITY_OTTO)
3101 + ? Metasync_Seo_Precedence::PRIORITY_OTTO
3102 + : 'custom';
2912 3103 }
2913 3104
2914 3105 if ($general_tab_submitted || $whitelabel_tab_submitted) {
2915 3106 foreach ($url_fields as $field) {
@@ -2914,9 +3105,10 @@
2914 3105 if ($general_tab_submitted || $whitelabel_tab_submitted) {
2915 3106 foreach ($url_fields as $field) {
2916 3107 if (isset($_POST['metasync_options']['general'][$field])) {
2917 3108
2918 - $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]));
2919 3111
2920 3112 $whitelabel_clearable_url_fields = [
2921 3113 'white_label_plugin_author_uri',
2922 3114 'white_label_plugin_uri'
@@ -2932,20 +3124,38 @@
2932 3124 }
2933 3125
2934 3126 if (filter_var($value, FILTER_VALIDATE_URL)) {
2935 3127
2936 - $parsed_url = parse_url($value);
3128 + $parsed_url = wp_parse_url($value);
2937 3129 $host = isset($parsed_url['host']) ? $parsed_url['host'] : '';
2938 3130
2939 3131 if (strpos($host, '.') !== false || filter_var($host, FILTER_VALIDATE_IP)) {
2940 - $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 + }
2941 3147 } else {
2942 3148 $field_name = ($field === 'white_label_plugin_author_uri') ? 'Author URL' : 'Plugin URL';
2943 - $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).';
2944 3154 }
2945 3155 } else {
2946 3156 $field_name = ($field === 'white_label_plugin_author_uri') ? 'Author URL' : 'Plugin URL';
2947 - $validation_errors[] = 'Invalid ' . $field_name . ' format.';
3157 + $branding_errors[$field] = $field_name . ' must be a valid http:// or https:// address.';
2948 3158 }
2949 3159 }
2950 3160 }
2951 3161 }
@@ -2951,31 +3161,54 @@
2951 3161 }
2952 3162
2953 3163 if ($general_tab_submitted) {
2954 3164 if (isset($_POST['metasync_options']['general']['content_genius_sync_roles']) && is_array($_POST['metasync_options']['general']['content_genius_sync_roles'])) {
2955 - $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']));
2956 3166 } else {
2957 3167 $metasync_options['general']['content_genius_sync_roles'] = array();
2958 3168 }
2959 3169 }
2960 3170
2961 - if ($general_tab_submitted) {
2962 - $breadcrumbs_input = isset($_POST['metasync_options']['breadcrumbs']) && is_array($_POST['metasync_options']['breadcrumbs'])
2963 - ? $_POST['metasync_options']['breadcrumbs']
2964 - : array();
3171 + // Breadcrumb settings live on their own admin page, which saves through
3172 + // options.php -> sanitize() rather than this AJAX handler. Only touch
3173 + // them here if the submitted form actually carried breadcrumb fields,
3174 + // otherwise a General-tab save would reset every breadcrumb option.
3175 + if ($general_tab_submitted
3176 + && isset($_POST['metasync_options']['breadcrumbs'])
3177 + && is_array($_POST['metasync_options']['breadcrumbs'])
3178 + ) {
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']);
2965 3181
2966 3182 if (!isset($metasync_options['breadcrumbs']) || !is_array($metasync_options['breadcrumbs'])) {
2967 3183 $metasync_options['breadcrumbs'] = array();
2968 3184 }
2969 3185
2970 - $metasync_options['breadcrumbs']['enabled'] = !empty($breadcrumbs_input['enabled']);
2971 - $metasync_options['breadcrumbs']['disable_schema'] = !empty($breadcrumbs_input['disable_schema']);
2972 - $metasync_options['breadcrumbs']['show_current_page'] = !empty($breadcrumbs_input['show_current_page']);
2973 - $metasync_options['breadcrumbs']['separator'] = isset($breadcrumbs_input['separator']) ? sanitize_text_field($breadcrumbs_input['separator']) : '»';
2974 - $metasync_options['breadcrumbs']['home_label'] = isset($breadcrumbs_input['home_label']) ? sanitize_text_field($breadcrumbs_input['home_label']) : 'Home';
2975 - $metasync_options['breadcrumbs']['home_url'] = isset($breadcrumbs_input['home_url']) ? esc_url_raw($breadcrumbs_input['home_url']) : '';
2976 - $metasync_options['breadcrumbs']['prefix_text'] = isset($breadcrumbs_input['prefix_text']) ? sanitize_text_field($breadcrumbs_input['prefix_text']) : '';
2977 - $metasync_options['breadcrumbs']['archive_label_format'] = isset($breadcrumbs_input['archive_label_format']) ? sanitize_text_field($breadcrumbs_input['archive_label_format']) : '{name}';
3186 + foreach (array('enabled', 'disable_schema', 'show_current_page') as $breadcrumb_flag) {
3187 + if (isset($breadcrumbs_input[$breadcrumb_flag])) {
3188 + $metasync_options['breadcrumbs'][$breadcrumb_flag] = !empty($breadcrumbs_input[$breadcrumb_flag]);
3189 + }
3190 + }
3191 + // An empty separator (Custom radio selected blank) keeps the
3192 + // stored value instead of rendering a blank gap between links.
3193 + if (isset($breadcrumbs_input['separator'])) {
3194 + $breadcrumb_separator = sanitize_text_field($breadcrumbs_input['separator']);
3195 + if ('' !== $breadcrumb_separator) {
3196 + $metasync_options['breadcrumbs']['separator'] = $breadcrumb_separator;
3197 + }
3198 + }
3199 + if (isset($breadcrumbs_input['home_label'])) {
3200 + $metasync_options['breadcrumbs']['home_label'] = sanitize_text_field($breadcrumbs_input['home_label']);
3201 + }
3202 + if (isset($breadcrumbs_input['home_url'])) {
3203 + $metasync_options['breadcrumbs']['home_url'] = esc_url_raw($breadcrumbs_input['home_url']);
3204 + }
3205 + if (isset($breadcrumbs_input['prefix_text'])) {
3206 + $metasync_options['breadcrumbs']['prefix_text'] = sanitize_text_field($breadcrumbs_input['prefix_text']);
3207 + }
3208 + if (isset($breadcrumbs_input['archive_label_format'])) {
3209 + $metasync_options['breadcrumbs']['archive_label_format'] = sanitize_text_field($breadcrumbs_input['archive_label_format']);
3210 + }
2978 3211 }
2979 3212
2980 3213 // Open Graph / article toggle checkboxes live under common_meta_settings.
2981 3214 // Unchecked HTML checkboxes are absent from $_POST, so we explicitly
@@ -2996,9 +3229,9 @@
2996 3229 $metasync_options['common_meta_settings'] = [];
2997 3230 }
2998 3231 foreach ($og_toggle_fields as $field) {
2999 3232 $posted = isset($_POST['metasync_options']['common_meta_settings'][$field])
3000 - ? sanitize_text_field($_POST['metasync_options']['common_meta_settings'][$field])
3233 + ? sanitize_text_field(wp_unslash($_POST['metasync_options']['common_meta_settings'][$field]))
3001 3234 : '';
3002 3235 $metasync_options['common_meta_settings'][$field] = ($posted === 'true') ? 'true' : 'false';
3003 3236 }
3004 3237 }
@@ -3005,14 +3238,15 @@
3005 3238
3006 3239 if ($general_tab_submitted && isset($_POST['metasync_options']['general']['default_page_builder'])) {
3007 3240 require_once plugin_dir_path(dirname(__FILE__)) . 'custom-pages/class-metasync-html-to-builder-converter.php';
3008 3241 $available_builders = Metasync_HTML_To_Builder_Converter::get_available_builders();
3009 - $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']));
3010 3243 if (isset($available_builders[$builder_value]) && $available_builders[$builder_value]['detected']) {
3011 3244 $metasync_options['general']['default_page_builder'] = $builder_value;
3012 3245 } else {
3013 3246 $metasync_options['general']['default_page_builder'] = 'gutenberg';
3014 3247 if (isset($available_builders[$builder_value]) && !$available_builders[$builder_value]['detected']) {
3248 + /* translators: %s: page builder name. */
3015 3249 $validation_errors[] = sprintf(__('Builder "%s" is not detected on this site. Reverted to Gutenberg.', 'metasync'), $available_builders[$builder_value]['label']);
3016 3250 }
3017 3251 }
3018 3252 }
@@ -3017,10 +3251,13 @@
3017 3251 }
3018 3252 }
3019 3253
3020 3254 if ($whitelabel_tab_submitted && isset($_POST['metasync_options']['whitelabel'])) {
3021 - $gp = isset($_POST['metasync_options']['general']) && is_array($_POST['metasync_options']['general']) ? $_POST['metasync_options']['general'] : [];
3022 - $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']) : [];
3023 3260 $plugin_name = isset($gp['white_label_plugin_name']) ? trim((string) $gp['white_label_plugin_name']) : '';
3024 3261 $logo_light = isset($wp['logo_light']) ? trim((string) $wp['logo_light']) : '';
3025 3262 $logo_dark = isset($wp['logo_dark']) ? trim((string) $wp['logo_dark']) : '';
3026 3263 $author = isset($gp['white_label_plugin_author']) ? trim((string) $gp['white_label_plugin_author']) : '';
@@ -3036,17 +3273,67 @@
3036 3273 $validation_errors[] = 'Add at least one of: Plugin Name, Logo URL, or Author to save whitelabel settings.';
3037 3274 }
3038 3275 }
3039 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 +
3040 3284 if (!empty($validation_errors)) {
3041 3285 $this->force_release_settings_recovery_lock();
3042 3286 wp_send_json_error([
3043 - 'errors' => $validation_errors
3287 + 'errors' => $validation_errors,
3288 + 'field_errors' => $branding_errors,
3044 3289 ]);
3045 3290 return;
3046 3291 }
3047 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 +
3048 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 +
3049 3336 // Stored value is encrypted at rest (or legacy plaintext/empty). Keep it as-is
3050 3337 // for preserve/revert, and decrypt a separate copy for plaintext comparison.
3051 3338 $old_stored_api_key = $old_options['searchatlas_api_key'] ?? '';
3052 3339 $old_api_key = Metasync::get_searchatlas_api_key();
@@ -3090,14 +3377,19 @@
3090 3377
3091 3378 if ( $ping['connected'] === true ) {
3092 3379 $api_key_validated = true;
3093 3380 $is_connected = true;
3094 - 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'] ) ) {
3095 3385 $existing_uuid = $metasync_options['general']['otto_pixel_uuid'] ?? '';
3096 3386 if ( empty( $existing_uuid ) || $existing_uuid !== $ping['otto_pixel_uuid'] ) {
3097 3387 $metasync_options['general']['otto_pixel_uuid'] = sanitize_text_field( $ping['otto_pixel_uuid'] );
3098 3388 Metasync::set_option($metasync_options);
3099 3389 }
3390 + } elseif ( ! empty( $ping['otto_pixel_uuid'] ) ) {
3391 + error_log('MetaSync: ignoring invalid otto_pixel_uuid from connection ping (not a UUID)');
3100 3392 }
3101 3393 } else {
3102 3394 // POST succeeded but ping didn't confirm — keep the key, warn user
3103 3395 $api_key_validated = true;
@@ -3149,8 +3441,15 @@
3149 3441 $api_key_changed = $old_api_key !== $new_api_key;
3150 3442 $api_key_added = empty($old_api_key) && !empty($new_api_key);
3151 3443 $api_key_removed = !empty($old_api_key) && empty($new_api_key);
3152 3444
3445 + if ( $api_key_removed ) {
3446 + Metasync_PostHog::feature('searchatlas_disconnected', array(
3447 + 'method' => 'key_removed',
3448 + 'was_connected' => true,
3449 + ));
3450 + }
3451 +
3153 3452 if ( $api_key_validated === true ) {
3154 3453 # Use current_time('mysql') so the value is consistent with the
3155 3454 # cron heartbeat writer and renders correctly via time_elapsed_string.
3156 3455 $send_auth_token_timestamp = Metasync::get_option();
@@ -3184,9 +3483,10 @@
3184 3483 }
3185 3484
3186 3485 if (isset($_POST['metasync_options']['whitelabel'])) {
3187 3486
3188 - $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']);
3189 3489 $existing_whitelabel = $metasync_options['whitelabel'] ?? [];
3190 3490
3191 3491 if (isset($whitelabel_data['logo'])) {
3192 3492 $logo_value = trim($whitelabel_data['logo']);
@@ -3227,9 +3527,9 @@
3227 3527 }
3228 3528 }
3229 3529
3230 3530 if (isset($whitelabel_data['settings_password'])) {
3231 - $password_value = (string) wp_unslash($whitelabel_data['settings_password']);
3531 + $password_value = (string) $whitelabel_data['settings_password'];
3232 3532
3233 3533 $hide_settings_enabled = isset($whitelabel_data['hide_settings']) && $whitelabel_data['hide_settings'] == '1';
3234 3534
3235 3535 if ($hide_settings_enabled && empty($password_value)) {
@@ -3327,9 +3627,9 @@
3327 3627
3328 3628 }
3329 3629
3330 3630 $redirect_url = isset($_GET['tab']) ?
3331 - 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']))) :
3332 3632 admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-settings');
3333 3633
3334 3634 // Also save edge cache settings if present in the payload (section renders inside this form)
3335 3635 if (class_exists('Metasync_Edge_Cache_Settings')) {
@@ -3336,8 +3636,9 @@
3336 3636 Metasync_Edge_Cache_Settings::save_from_post();
3337 3637 }
3338 3638
3339 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.
3340 3641 $llms_input = wp_unslash($_POST['metasync_llms_txt_settings']);
3341 3642 if (is_array($llms_input)) {
3342 3643 update_option('metasync_llms_txt_settings', $this->sanitize_llms_txt_settings($llms_input));
3343 3644 }
@@ -3342,8 +3643,37 @@
3342 3643 update_option('metasync_llms_txt_settings', $this->sanitize_llms_txt_settings($llms_input));
3343 3644 }
3344 3645 }
3345 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 +
3346 3676 $response_payload = array(
3347 3677 'message' => 'Settings saved successfully!',
3348 3678 'redirect_url' => $redirect_url,
3349 3679 );
@@ -3389,9 +3719,10 @@
3389 3719 * Moved from Metasync_Admin – the admin class now delegates here.
3390 3720 */
3391 3721 public function ajax_save_execution_settings()
3392 3722 {
3393 - 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')) {
3394 3725 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
3395 3726 return;
3396 3727 }
3397 3728
@@ -3485,9 +3816,579 @@
3485 3816 wp_send_json_error(array('message' => 'Failed to save settings. Please try again.'));
3486 3817 }
3487 3818 }
3488 3819
3820 + # ------------------------------------------------------------------
3821 + # Headless Mode settings on the Advanced tab
3822 + # ------------------------------------------------------------------
3823 + # Metasync_Headless_Config is the only sanitiser for these four values.
3824 + # Nothing here validates a domain, a policy or a prefix — it collects
3825 + # what was submitted, hands it to update_settings(), and then shows back
3826 + # whatever that stored, so a value the sanitiser refused reads as refused
3827 + # instead of being echoed back as though it saved.
3828 + # ------------------------------------------------------------------
3829 +
3489 3830 /**
3831 + * Field prefix shared by every control in the Headless Mode block.
3832 + *
3833 + * The controls sit inside the Advanced tab's outer form, which posts to
3834 + * options.php. Naming them outside `metasync_options[...]` keeps them out
3835 + * of sanitize() entirely, so an unrelated save of that form can neither
3836 + * store them unsanitised nor clear them.
3837 + */
3838 + const HEADLESS_FIELD_PREFIX = 'metasync_headless_';
3839 +
3840 + /**
3841 + * Nonce action and request field for the Headless Mode save.
3842 + */
3843 + const HEADLESS_NONCE_ACTION = 'metasync_headless_settings_nonce';
3844 + const HEADLESS_NONCE_FIELD = 'headless_settings_nonce';
3845 +
3846 + /**
3847 + * Render the Headless Mode section of the Advanced settings accordion.
3848 + *
3849 + * Values come from Metasync_Headless_Config::get_settings(), which is the
3850 + * sanitised stored state rather than anything the operator typed.
3851 + *
3852 + * @return void
3853 + */
3854 + public function render_headless_mode_section()
3855 + {
3856 + $settings = Metasync_Headless_Config::get_settings();
3857 + $enabled = !empty($settings['enabled']);
3858 + $active = Metasync_Headless_Config::is_active();
3859 + $prefix = self::HEADLESS_FIELD_PREFIX;
3860 +
3861 + $policy_labels = array(
3862 + Metasync_Headless_Config::SLASH_PRESERVE => 'Preserve — hand back whatever WordPress produced (default)',
3863 + Metasync_Headless_Config::SLASH_STRIP => 'Strip — no trailing slash',
3864 + Metasync_Headless_Config::SLASH_ADD => 'Add — always end with a trailing slash',
3865 + );
3866 + ?>
3867 + <div style="background: var(--dashboard-card-bg); padding: 20px; border-radius: 8px;">
3868 + <p style="color: var(--dashboard-text-secondary); margin: 0 0 20px 0;">
3869 + Headless Mode describes a separate frontend that serves the public site, so this plugin can build
3870 + URLs on that host instead of this one. With the mode off nothing here changes how the site behaves.
3871 + </p>
3872 +
3873 + <div style="background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.3); border-radius: 8px; padding: 16px; margin-bottom: 20px;">
3874 + <p style="color: var(--dashboard-text-secondary); margin: 0;">
3875 + Headless Mode is for sites where WordPress manages content and a separate frontend serves the public pages.
3876 + Set the public frontend URL below, then choose how MetaSync should build those public URLs.
3877 + </p>
3878 + </div>
3879 +
3880 + <div id="metasync-headless-settings-message" style="display: none; margin-bottom: 20px;"></div>
3881 +
3882 + <form id="metasync-headless-settings-form" method="post" data-headless-active="<?php echo $active ? '1' : '0'; ?>">
3883 + <?php wp_nonce_field(self::HEADLESS_NONCE_ACTION, self::HEADLESS_NONCE_FIELD); ?>
3884 +
3885 + <div style="background: var(--dashboard-card-bg-alt, rgba(255,255,255,0.05)); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
3886 + <label for="<?php echo esc_attr($prefix . 'enabled'); ?>" style="display: flex; align-items: center; gap: 10px; color: var(--dashboard-text-primary); font-weight: 500; cursor: pointer;">
3887 + <?php
3888 + # Hidden field first, checkbox second, same name. An unchecked
3889 + # checkbox posts nothing at all, and normalize() reads an absent
3890 + # `enabled` key as false — so without this pair, a submission that
3891 + # simply did not include the checkbox would switch the mode off as
3892 + # a side effect. With it, "off" arrives as an explicit 0.
3893 + ?>
3894 + <input type="hidden" name="<?php echo esc_attr($prefix . 'enabled'); ?>" value="0" />
3895 + <input type="checkbox"
3896 + id="<?php echo esc_attr($prefix . 'enabled'); ?>"
3897 + name="<?php echo esc_attr($prefix . 'enabled'); ?>"
3898 + value="1"
3899 + style="width: 18px; height: 18px; cursor: pointer;"
3900 + <?php checked($enabled, true); ?> />
3901 + <span>Enable Headless Mode</span>
3902 + </label>
3903 +
3904 + <div style="background: rgba(245, 158, 11, 0.1); border: 1px solid rgba(245, 158, 11, 0.3); border-radius: 8px; padding: 16px; margin: 16px 0 0 0;">
3905 + <p style="color: var(--dashboard-text-secondary); margin: 0; font-size: 13px;">
3906 + <strong style="color: var(--dashboard-warning, #f59e0b);">Changing this can affect your public frontend.</strong><br />
3907 + When Headless Mode is active, the GraphQL SEO field and generated URLs follow the settings below.
3908 + Coordinate with whoever maintains the frontend before disabling it or changing its public URL.
3909 + </p>
3910 + </div>
3911 +
3912 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 12px 0 0 0;">
3913 + Current state:
3914 + <strong style="color: var(--dashboard-text-primary);"><?php echo $enabled ? 'enabled' : 'disabled'; ?></strong><?php
3915 + if ($enabled && !$active) {
3916 + ?> &mdash; but not yet in effect: a frontend domain is required before any headless URL can be built.<?php
3917 + }
3918 + ?>
3919 + </p>
3920 + </div>
3921 +
3922 + <div style="background: var(--dashboard-card-bg-alt, rgba(255,255,255,0.05)); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
3923 + <div style="margin-bottom: 24px;">
3924 + <label for="<?php echo esc_attr($prefix . 'frontend_domain'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px; font-weight: 500;">
3925 + Public frontend URL:
3926 + </label>
3927 + <input type="url"
3928 + id="<?php echo esc_attr($prefix . 'frontend_domain'); ?>"
3929 + name="<?php echo esc_attr($prefix . 'frontend_domain'); ?>"
3930 + value="<?php echo esc_attr($settings['frontend_domain']); ?>"
3931 + placeholder="https://www.example.com"
3932 + style="width: 100%; max-width: 420px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg); color: var(--dashboard-text-primary);" />
3933 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3934 + Scheme and host only &mdash; for example <code>https://www.example.com</code>. A path is
3935 + refused: <code>https://example.com/blog</code> will not save, because only per-post-type
3936 + prefixes exist and a shared path segment could never be put back on terms, archives or
3937 + the homepage. Use a path prefix below instead. A bare host is treated as https.
3938 + </p>
3939 + </div>
3940 +
3941 + <div style="margin-bottom: 24px;">
3942 + <label for="<?php echo esc_attr($prefix . 'slash_policy'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px; font-weight: 500;">
3943 + Trailing Slash Policy:
3944 + </label>
3945 + <select id="<?php echo esc_attr($prefix . 'slash_policy'); ?>"
3946 + name="<?php echo esc_attr($prefix . 'slash_policy'); ?>"
3947 + style="max-width: 420px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg); color: var(--dashboard-text-primary);">
3948 + <?php foreach (Metasync_Headless_Config::slash_policies() as $policy) { ?>
3949 + <option value="<?php echo esc_attr($policy); ?>" <?php selected($settings['slash_policy'], $policy); ?>>
3950 + <?php echo esc_html(isset($policy_labels[$policy]) ? $policy_labels[$policy] : $policy); ?>
3951 + </option>
3952 + <?php } ?>
3953 + </select>
3954 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3955 + Choose how generated public URLs end. For example, <code>/about</code> stays unchanged with Preserve,
3956 + becomes <code>/about</code> with Strip, or <code>/about/</code> with Add. Match your frontend router.
3957 + </p>
3958 + </div>
3959 +
3960 + <div>
3961 + <label for="<?php echo esc_attr($prefix . 'path_prefixes'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px; font-weight: 500;">
3962 + Path Prefixes:
3963 + </label>
3964 + <textarea id="<?php echo esc_attr($prefix . 'path_prefixes'); ?>"
3965 + name="<?php echo esc_attr($prefix . 'path_prefixes'); ?>"
3966 + rows="4"
3967 + placeholder="post = blog"
3968 + style="width: 100%; max-width: 420px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg); color: var(--dashboard-text-primary); font-family: monospace;"><?php echo esc_textarea(self::format_headless_path_prefixes($settings['path_prefixes'])); ?></textarea>
3969 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3970 + Map WordPress content types to frontend URL folders:
3971 + </p>
3972 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 8px 0 0 0;">
3973 + One per line, for example <code>post = blog</code> produces <code>/blog/example-post</code>.
3974 + Leave empty when your frontend uses the same paths as WordPress.
3975 + </p>
3976 + </div>
3977 +
3978 + <details style="margin-top: 24px;">
3979 + <summary style="color: var(--dashboard-text-primary); font-weight: 500; cursor: pointer;">Advanced refresh settings</summary>
3980 + <div style="padding-top: 16px;">
3981 + <label for="<?php echo esc_attr($prefix . 'refresh_interval_minutes'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px;">
3982 + Background refresh interval (minutes):
3983 + </label>
3984 + <input type="number"
3985 + id="<?php echo esc_attr($prefix . 'refresh_interval_minutes'); ?>"
3986 + name="<?php echo esc_attr($prefix . 'refresh_interval_minutes'); ?>"
3987 + value="<?php echo esc_attr($settings['refresh_interval_minutes']); ?>"
3988 + min="<?php echo esc_attr((string) Metasync_Headless_Config::REFRESH_INTERVAL_MIN_MINUTES); ?>"
3989 + max="<?php echo esc_attr((string) Metasync_Headless_Config::REFRESH_INTERVAL_MAX_MINUTES); ?>"
3990 + step="1"
3991 + style="width: 100%; max-width: 200px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg); color: var(--dashboard-text-primary);" />
3992 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3993 + How often the background job re-checks a URL's stored OTTO data against OTTO directly, for
3994 + any post or term OTTO's deployment webhook missed. Does not affect the webhook, which stays
3995 + the primary, immediate update path. Between
3996 + <?php echo esc_html((string) Metasync_Headless_Config::REFRESH_INTERVAL_MIN_MINUTES); ?> and
3997 + <?php echo esc_html((string) Metasync_Headless_Config::REFRESH_INTERVAL_MAX_MINUTES); ?> minutes;
3998 + defaults to <?php echo esc_html((string) Metasync_Headless_Config::REFRESH_INTERVAL_DEFAULT_MINUTES); ?>.
3999 + </p>
4000 + </div>
4001 + </details>
4002 + </div>
4003 +
4004 + <button type="button"
4005 + id="metasync-headless-settings-save-btn"
4006 + class="metasync-btn-primary"
4007 + style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 12px 24px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);">
4008 + <span class="save-text">Save Headless Settings</span>
4009 + </button>
4010 + </form>
4011 + </div>
4012 + <script>
4013 + jQuery(document).ready(function ($) {
4014 + // Click, not submit: this form is nested inside #metaSyncGeneralSetting,
4015 + // so the browser drops the inner form tag and a submit handler would
4016 + // never fire on its own.
4017 + var $saveBtn = $('#metasync-headless-settings-save-btn');
4018 + var $message = $('#metasync-headless-settings-message');
4019 + var headlessModeWasActive = <?php echo $active ? 'true' : 'false'; ?>;
4020 + var headlessDisableWarning = 'Turning off Headless Mode removes the metasyncSeo field from the GraphQL schema. Any frontend querying it will error until it is redeployed. Continue?';
4021 + var headlessDomainWarning = 'The metasyncSeo field stays in the GraphQL schema, but every URL it returns will be empty until a frontend domain is set again. Continue?';
4022 + var headlessModeSavedSettings = <?php echo wp_json_encode(array(
4023 + 'enabled' => !empty($settings['enabled']),
4024 + 'frontend_domain' => $settings['frontend_domain'],
4025 + 'slash_policy' => $settings['slash_policy'],
4026 + 'path_prefixes' => self::format_headless_path_prefixes($settings['path_prefixes']),
4027 + 'refresh_interval_minutes' => $settings['refresh_interval_minutes'],
4028 + )); ?>;
4029 +
4030 + // This mirrors normalize_domain()'s shape rules so a value the
4031 + // server will refuse cannot look active here. Keep this deliberately
4032 + // small: it is a confirmation decision, not a client-side validator,
4033 + // and the server remains authoritative. Mirrors can drift.
4034 + function isUsableFrontendDomain(value) {
4035 + // PHP's trim() strips only space, tab, newline, carriage return,
4036 + // NUL and vertical tab, while $.trim() and String.trim() strip the
4037 + // whole Unicode set. Trimming a non-breaking space or BOM that the
4038 + // server keeps would make a pasted domain look usable while
4039 + // normalize_domain() rejects it — the guard stays silent and the
4040 + // mode deactivates anyway. Match the server charlist exactly.
4041 + var candidate = (value || '').replace(/^[ \t\n\r\0\x0B]+|[ \t\n\r\0\x0B]+$/g, '');
4042 + var parsed;
4043 + var authority;
4044 + var host;
4045 + var path;
4046 +
4047 + if (candidate === '') {
4048 + return false;
4049 + }
4050 +
4051 + if (candidate.indexOf('//') === -1) {
4052 + candidate = 'https://' + candidate.replace(/^\/+/, '');
4053 + }
4054 +
4055 + parsed = candidate.match(/^(?:([a-z][a-z0-9+.-]*):)?\/\/([^/?#]*)([^?#]*)/i);
4056 + if (!parsed || (parsed[1] && parsed[1].toLowerCase() !== 'http' && parsed[1].toLowerCase() !== 'https')) {
4057 + return false;
4058 + }
4059 +
4060 + path = parsed[3] || '';
4061 + if (path.replace(/^\/+|\/+$/g, '') !== '') {
4062 + return false;
4063 + }
4064 +
4065 + authority = parsed[2].replace(/^.*@/, '');
4066 + if (authority.charAt(0) === '[') {
4067 + return /^\[[0-9a-f:.]+\](?::\d+)?$/i.test(authority);
4068 + }
4069 +
4070 + host = authority.replace(/:\d+$/, '');
4071 + if (!/^[\p{L}\p{N}._-]+$/u.test(host) || host.indexOf('..') !== -1) {
4072 + return false;
4073 + }
4074 +
4075 + return !/^[.-]|[.-]$/.test(host) && /[\p{L}\p{N}]/u.test(host);
4076 + }
4077 +
4078 + function showMessage(lines, type) {
4079 + var $list = $('<div/>');
4080 + $.each(lines, function (i, line) {
4081 + $list.append($('<p/>').css('margin', i === 0 ? '0' : '8px 0 0 0').text(line));
4082 + });
4083 +
4084 + $message
4085 + .css({
4086 + 'background': type === 'success' ? 'rgba(34, 197, 94, 0.1)' : 'rgba(239, 68, 68, 0.1)',
4087 + 'border': '1px solid ' + (type === 'success' ? 'rgba(34, 197, 94, 0.3)' : 'rgba(239, 68, 68, 0.3)'),
4088 + 'color': type === 'success' ? '#22c55e' : '#ef4444',
4089 + 'padding': '12px 16px',
4090 + 'border-radius': '6px',
4091 + 'font-size': '14px',
4092 + 'line-height': '1.5'
4093 + })
4094 + .empty()
4095 + .append($list)
4096 + .show();
4097 + }
4098 +
4099 + // Repaint from what was actually stored, never from what was typed, so a
4100 + // refused value reads as refused rather than looking saved.
4101 + function applyStoredSettings(settings) {
4102 + if (!settings) {
4103 + return;
4104 + }
4105 + headlessModeSavedSettings = $.extend({}, settings);
4106 + headlessModeWasActive = !!settings.enabled && isUsableFrontendDomain(settings.frontend_domain);
4107 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>').prop('checked', !!settings.enabled);
4108 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>').val(settings.frontend_domain);
4109 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'slash_policy'); ?>').val(settings.slash_policy);
4110 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'path_prefixes'); ?>').val(settings.path_prefixes);
4111 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'refresh_interval_minutes'); ?>').val(settings.refresh_interval_minutes);
4112 + }
4113 +
4114 + $saveBtn.on('click', function (e) {
4115 + e.preventDefault();
4116 +
4117 + var data = {
4118 + action: 'metasync_save_headless_settings',
4119 + <?php echo esc_js(self::HEADLESS_NONCE_FIELD); ?>: $('#<?php echo esc_js(self::HEADLESS_NONCE_FIELD); ?>').val(),
4120 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>').is(':checked') ? '1' : '0',
4121 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>').val(),
4122 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'slash_policy'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'slash_policy'); ?>').val(),
4123 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'path_prefixes'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'path_prefixes'); ?>').val(),
4124 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'refresh_interval_minutes'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'refresh_interval_minutes'); ?>').val()
4125 + };
4126 +
4127 + var submittedModeIsEnabled = data['<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>'] === '1';
4128 + var submittedDomain = data['<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>'];
4129 + var submittedModeIsActive = submittedModeIsEnabled
4130 + && isUsableFrontendDomain(submittedDomain);
4131 +
4132 + if (submittedModeIsEnabled && !submittedModeIsActive) {
4133 + showMessage(['Enter a valid public frontend URL before enabling Headless Mode. Use a scheme and host only, for example https://www.example.com.'], 'error');
4134 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>').trigger('focus');
4135 + return;
4136 + }
4137 +
4138 + var confirmationMessage = submittedModeIsEnabled ? headlessDomainWarning : headlessDisableWarning;
4139 +
4140 + if (headlessModeWasActive && !submittedModeIsActive && !window.confirm(confirmationMessage)) {
4141 + applyStoredSettings(headlessModeSavedSettings);
4142 + return;
4143 + }
4144 +
4145 + $saveBtn.prop('disabled', true);
4146 + $saveBtn.find('.save-text').text('Saving...');
4147 + $message.hide();
4148 +
4149 + $.ajax({
4150 + url: window.ajaxurl,
4151 + type: 'POST',
4152 + data: data,
4153 + success: function (response) {
4154 + if (response && response.success) {
4155 + applyStoredSettings(response.data.settings);
4156 + var lines = [response.data.message];
4157 + if (response.data.notes && response.data.notes.length) {
4158 + lines = lines.concat(response.data.notes);
4159 + }
4160 + showMessage(lines, response.data.notes && response.data.notes.length ? 'error' : 'success');
4161 + } else {
4162 + showMessage([(response && response.data && response.data.message) || 'Error saving Headless Mode settings.'], 'error');
4163 + }
4164 + $saveBtn.prop('disabled', false);
4165 + $saveBtn.find('.save-text').text('Save Headless Settings');
4166 + },
4167 + error: function () {
4168 + showMessage(['An error occurred while saving Headless Mode settings. Please try again.'], 'error');
4169 + $saveBtn.prop('disabled', false);
4170 + $saveBtn.find('.save-text').text('Save Headless Settings');
4171 + }
4172 + });
4173 + });
4174 + });
4175 + </script>
4176 + <?php
4177 + }
4178 +
4179 + /**
4180 + * Render the stored post-type => prefix map back into the textarea format.
4181 + *
4182 + * @param mixed $prefixes Stored prefix map.
4183 + * @return string One `post_type = prefix` entry per line.
4184 + */
4185 + public static function format_headless_path_prefixes($prefixes)
4186 + {
4187 + if (!is_array($prefixes)) {
4188 + return '';
4189 + }
4190 +
4191 + $lines = array();
4192 +
4193 + foreach ($prefixes as $post_type => $prefix) {
4194 + $lines[] = $post_type . ' = ' . $prefix;
4195 + }
4196 +
4197 + return implode("\n", $lines);
4198 + }
4199 +
4200 + /**
4201 + * Read the textarea format back into a post-type => prefix map.
4202 + *
4203 + * One entry per line, `post_type = prefix`. A plain text format is enough
4204 + * for a demo and needs no repeater UI. A line with no `=` names no post
4205 + * type, so it is skipped rather than guessed at; everything that does
4206 + * survive is still handed to Metasync_Headless_Config::normalize(), which
4207 + * is what actually decides whether a post type and prefix are usable.
4208 + *
4209 + * @param mixed $text Raw textarea contents.
4210 + * @return array<string,string> Unsanitised map, keyed by post type.
4211 + */
4212 + public static function parse_headless_path_prefixes($text)
4213 + {
4214 + $map = array();
4215 +
4216 + if (!is_string($text)) {
4217 + return $map;
4218 + }
4219 +
4220 + foreach (preg_split('/\R/', $text) as $line) {
4221 + if (strpos($line, '=') === false) {
4222 + continue;
4223 + }
4224 +
4225 + list($post_type, $prefix) = explode('=', $line, 2);
4226 +
4227 + $post_type = trim($post_type);
4228 + if ($post_type === '') {
4229 + continue;
4230 + }
4231 +
4232 + $map[$post_type] = trim($prefix);
4233 + }
4234 +
4235 + return $map;
4236 + }
4237 +
4238 + /**
4239 + * Merge a Headless Mode submission over the stored settings and persist it.
4240 + *
4241 + * Additive on purpose: a key the request did not carry keeps whatever is
4242 + * already stored. normalize() treats an absent `enabled` as false, so a
4243 + * submission that omitted the checkbox would otherwise switch the mode off
4244 + * as a side effect of saving something else. The hidden field in front of
4245 + * the checkbox is the other half of that guard — it makes "off" an explicit
4246 + * `0` rather than an absence, so unticking the box still works.
4247 + *
4248 + * Sanitising is left entirely to Metasync_Headless_Config.
4249 + *
4250 + * @param mixed $submitted Raw request data, normally $_POST.
4251 + * @return array The settings that were actually stored.
4252 + */
4253 + public function save_headless_settings($submitted)
4254 + {
4255 + if (!is_array($submitted)) {
4256 + $submitted = array();
4257 + }
4258 +
4259 + $prefix = self::HEADLESS_FIELD_PREFIX;
4260 + $input = Metasync_Headless_Config::get_settings();
4261 +
4262 + if (isset($submitted[$prefix . 'enabled'])) {
4263 + $input['enabled'] = $submitted[$prefix . 'enabled'];
4264 + }
4265 +
4266 + if (isset($submitted[$prefix . 'frontend_domain'])) {
4267 + $input['frontend_domain'] = wp_unslash($submitted[$prefix . 'frontend_domain']);
4268 + }
4269 +
4270 + if (isset($submitted[$prefix . 'slash_policy'])) {
4271 + $input['slash_policy'] = $submitted[$prefix . 'slash_policy'];
4272 + }
4273 +
4274 + if (isset($submitted[$prefix . 'path_prefixes'])) {
4275 + $input['path_prefixes'] = self::parse_headless_path_prefixes(
4276 + wp_unslash($submitted[$prefix . 'path_prefixes'])
4277 + );
4278 + }
4279 +
4280 + if (isset($submitted[$prefix . 'refresh_interval_minutes'])) {
4281 + $input['refresh_interval_minutes'] = $submitted[$prefix . 'refresh_interval_minutes'];
4282 + }
4283 +
4284 + return Metasync_Headless_Config::update_settings($input);
4285 + }
4286 +
4287 + /**
4288 + * AJAX handler for the Headless Mode block on the Advanced tab.
4289 + *
4290 + * Same nonce-then-capability shape as the other Advanced tab handlers.
4291 + *
4292 + * @return void
4293 + */
4294 + /**
4295 + * Did the last write actually land?
4296 + *
4297 + * update_settings() hands back what it meant to store, not proof that it
4298 + * reached the database: a full disk, a failing object cache or a read-only
4299 + * replica all produce a silent no-op. Telling an operator their headless
4300 + * settings saved when they did not is worse than telling them nothing —
4301 + * they would go on to wonder why the frontend sees none of it.
4302 + *
4303 + * @param array $intended What the save call reported storing.
4304 + * @return bool
4305 + */
4306 + private function headless_settings_persisted(array $intended)
4307 + {
4308 + Metasync_Headless_Config::flush();
4309 +
4310 + return Metasync_Headless_Config::get_settings() == $intended;
4311 + }
4312 +
4313 + public function ajax_save_headless_settings()
4314 + {
4315 + $prefix = self::HEADLESS_FIELD_PREFIX;
4316 +
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)) {
4319 + wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
4320 + } elseif (!Metasync::current_user_has_plugin_access()) {
4321 + wp_send_json_error(array('message' => 'Insufficient permissions to save settings.'));
4322 + } else {
4323 + // phpcs:disable WordPress.Security.ValidatedSanitizedInput -- sanitised via Metasync_Headless_Config::normalize_domain() inside save_headless_settings().
4324 + $submitted_domain = isset($_POST[$prefix . 'frontend_domain'])
4325 + ? trim((string) wp_unslash($_POST[$prefix . 'frontend_domain']))
4326 + : '';
4327 + // phpcs:enable WordPress.Security.ValidatedSanitizedInput
4328 +
4329 + $stored = $this->save_headless_settings($_POST);
4330 +
4331 + if (!$this->headless_settings_persisted($stored)) {
4332 + wp_send_json_error(array(
4333 + 'message' => 'Those settings could not be saved. Nothing has changed — please try again, and check the site error log if it keeps happening.',
4334 + ));
4335 + }
4336 +
4337 + # Say so when the sanitiser refused something, rather than leaving the
4338 + # operator to spot that a field came back empty.
4339 + $notes = array();
4340 +
4341 + if ($submitted_domain !== '' && $stored['frontend_domain'] === '') {
4342 + $notes[] = 'That frontend domain was not saved. Give a scheme and host only, with no path — for example https://www.example.com.';
4343 + }
4344 +
4345 + if (!empty($stored['enabled']) && $stored['frontend_domain'] === '') {
4346 + $notes[] = 'Headless Mode is switched on but has no usable frontend domain, so nothing behaves as headless yet.';
4347 + }
4348 +
4349 + wp_send_json_success(array(
4350 + 'message' => 'Headless Mode settings saved.',
4351 + 'notes' => $notes,
4352 + 'settings' => array(
4353 + 'enabled' => !empty($stored['enabled']),
4354 + 'frontend_domain' => $stored['frontend_domain'],
4355 + 'slash_policy' => $stored['slash_policy'],
4356 + 'path_prefixes' => self::format_headless_path_prefixes($stored['path_prefixes']),
4357 + 'refresh_interval_minutes' => $stored['refresh_interval_minutes'],
4358 + ),
4359 + ));
4360 + }
4361 + }
4362 +
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 + /**
3490 4391 * AJAX handler for saving SEO / Indexation Control settings.
3491 4392 *
3492 4393 * Moved from Metasync_Admin – the admin class now delegates here.
3493 4394 */
@@ -3493,13 +4394,26 @@
3493 4394 */
3494 4395 public function meta_sync_save_seo_controls()
3495 4396 {
3496 4397 try {
3497 - 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')) {
3498 4400 wp_send_json_error(array('message' => 'Invalid nonce'));
3499 4401 return;
3500 4402 }
3501 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 +
3502 4416 $current_options = Metasync::get_option();
3503 4417
3504 4418 $original_options = json_decode(json_encode($current_options), true);
3505 4419
@@ -3549,8 +4463,22 @@
3549 4463 $result = true;
3550 4464 }
3551 4465
3552 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 +
3553 4481 $success_message = 'Indexation Control settings saved successfully!';
3554 4482 if (isset($_POST['metasync_bing_api_key_inline'])) {
3555 4483 $success_message = 'Indexation Control and Bing Instant Indexing settings saved successfully!';
3556 4484 }
@@ -3565,10 +4493,9 @@
3565 4493 }
3566 4494 wp_send_json_error(array('message' => $error_message));
3567 4495 }
3568 4496 } catch (Exception $e) {
3569 - error_log('Indexation Control AJAX Exception: ' . $e->getMessage());
3570 - 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
3571 4498 wp_send_json_error(array('message' => 'Error saving settings: ' . $e->getMessage()));
3572 4499 }
3573 4500 }
3574 4501
@@ -3580,12 +4507,12 @@
3580 4507 * @return bool True on success, false on failure
3581 4508 */
3582 4509 public function save_bing_inline_settings_ajax()
3583 4510 {
3584 - $api_key = isset($_POST['metasync_bing_api_key_inline']) ? sanitize_text_field(wp_unslash($_POST['metasync_bing_api_key_inline'])) : '';
3585 - $endpoint = isset($_POST['metasync_bing_endpoint_inline']) ? sanitize_text_field(wp_unslash($_POST['metasync_bing_endpoint_inline'])) : 'indexnow';
3586 - $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'])) : [];
3587 - $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
3588 4515
3589 4516 $existing_settings = get_option('metasync_options_bing_instant_indexing', []);
3590 4517
3591 4518 $new_settings = [
@@ -3609,9 +4536,9 @@
3609 4536 $safe_key = sanitize_file_name( $api_key );
3610 4537 $file_path = ABSPATH . $safe_key . '.txt';
3611 4538 $real_path = realpath( ABSPATH );
3612 4539 if ( false === $real_path || 0 !== strpos( realpath( dirname( $file_path ) ), $real_path ) ) {
3613 - 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
3614 4541 return false;
3615 4542 }
3616 4543 // serve the key virtually via template_redirect instead of
3617 4544 // writing a physical {key}.txt. An on-disk file is served statically
@@ -3624,9 +4551,9 @@
3624 4551 }
3625 4552 Metasync_Bing_Instant_Index::register_virtual_key($safe_key);
3626 4553
3627 4554 if (file_exists($file_path)) {
3628 - @unlink($file_path);
4555 + @wp_delete_file($file_path);
3629 4556 }
3630 4557
3631 4558 $current_options = Metasync::get_option();
3632 4559 if (!isset($current_options['seo_controls']['enable_binginstantindex']) || $current_options['seo_controls']['enable_binginstantindex'] !== 'true') {