PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | includes/class-metasync-settings-registration.php +882 -311 2.6.25 → 2.7.1 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;
@@ -342,9 +343,9 @@
342 343 $value = isset($settings['max_posts']) ? (int) $settings['max_posts'] : 50;
343 344 echo '<div class="llms-txt-dependent">';
344 345 printf(
345 346 '<input type="number" id="llms_txt_max_posts" name="metasync_llms_txt_settings[max_posts]" min="1" max="500" value="%d" />',
346 - $value
347 + absint($value)
347 348 );
348 349 echo '</div>';
349 350 },
350 351 $page_slug . '_general',
@@ -415,9 +416,9 @@
415 416 $value = isset($settings['max_posts_full']) ? (int) $settings['max_posts_full'] : 25;
416 417 echo '<div class="llms-full-dependent">';
417 418 printf(
418 419 '<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
420 + absint($value)
420 421 );
421 422 echo '<p class="description">' . esc_html__('Maximum posts for /llms-full.txt (lower than summary to protect server performance).', 'metasync') . '</p>';
422 423 echo '</div>';
423 424 },
@@ -453,12 +454,12 @@
453 454 'Disallow Empty Archives',
454 455 function() use ($option_key) {
455 456 $noindex_empty_archives = Metasync::get_option('seo_controls')['noindex_empty_archives'] ?? 'false';
456 457 printf(
457 - '<input type="checkbox" id="noindex_empty_archives" name="' . $option_key . '[seo_controls][noindex_empty_archives]" value="true" %s />',
458 + '<input type="checkbox" id="noindex_empty_archives" name="' . esc_attr($option_key) . '[seo_controls][noindex_empty_archives]" value="true" %s />',
458 459 isset($noindex_empty_archives) && $noindex_empty_archives == 'true' ? 'checked' : ''
459 460 );
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>');
461 + 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 462 },
462 463 $page_slug . '_seo-controls',
463 464 $SECTION_SEO_CONTROLS
464 465 );
@@ -468,12 +469,12 @@
468 469 'Disallow Date Archives',
469 470 function() use ($option_key) {
470 471 $index_date_archives = Metasync::get_option('seo_controls')['index_date_archives'] ?? 'false';
471 472 printf(
472 - '<input type="checkbox" id="index_date_archives" name="' . $option_key . '[seo_controls][index_date_archives]" value="true" %s />',
473 + '<input type="checkbox" id="index_date_archives" name="' . esc_attr($option_key) . '[seo_controls][index_date_archives]" value="true" %s />',
473 474 isset($index_date_archives) && $index_date_archives == 'true' ? 'checked' : ''
474 475 );
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>');
476 + 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 477 },
477 478 $page_slug . '_seo-controls',
478 479 $SECTION_SEO_CONTROLS
479 480 );
@@ -483,12 +484,12 @@
483 484 'Disallow Tag Archives',
484 485 function() use ($option_key) {
485 486 $index_tag_archives = Metasync::get_option('seo_controls')['index_tag_archives'] ?? 'false';
486 487 printf(
487 - '<input type="checkbox" id="index_tag_archives" name="' . $option_key . '[seo_controls][index_tag_archives]" value="true" %s />',
488 + '<input type="checkbox" id="index_tag_archives" name="' . esc_attr($option_key) . '[seo_controls][index_tag_archives]" value="true" %s />',
488 489 isset($index_tag_archives) && $index_tag_archives == 'true' ? 'checked' : ''
489 490 );
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>');
491 + 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 492 },
492 493 $page_slug . '_seo-controls',
493 494 $SECTION_SEO_CONTROLS
494 495 );
@@ -498,12 +499,12 @@
498 499 'Disallow Author Archives',
499 500 function() use ($option_key) {
500 501 $index_author_archives = Metasync::get_option('seo_controls')['index_author_archives'] ?? 'false';
501 502 printf(
502 - '<input type="checkbox" id="index_author_archives" name="' . $option_key . '[seo_controls][index_author_archives]" value="true" %s />',
503 + '<input type="checkbox" id="index_author_archives" name="' . esc_attr($option_key) . '[seo_controls][index_author_archives]" value="true" %s />',
503 504 isset($index_author_archives) && $index_author_archives == 'true' ? 'checked' : ''
504 505 );
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>');
506 + 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 507 },
507 508 $page_slug . '_seo-controls',
508 509 $SECTION_SEO_CONTROLS
509 510 );
@@ -513,12 +514,12 @@
513 514 'Disallow Format Archives',
514 515 function() use ($option_key) {
515 516 $index_format_archives = Metasync::get_option('seo_controls')['index_format_archives'] ?? 'false';
516 517 printf(
517 - '<input type="checkbox" id="index_format_archives" name="' . $option_key . '[seo_controls][index_format_archives]" value="true" %s />',
518 + '<input type="checkbox" id="index_format_archives" name="' . esc_attr($option_key) . '[seo_controls][index_format_archives]" value="true" %s />',
518 519 isset($index_format_archives) && $index_format_archives == 'true' ? 'checked' : ''
519 520 );
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>');
521 + 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 522 },
522 523 $page_slug . '_seo-controls',
523 524 $SECTION_SEO_CONTROLS
524 525 );
@@ -528,12 +529,12 @@
528 529 'Disallow Category Archives',
529 530 function() use ($option_key) {
530 531 $index_category_archives = Metasync::get_option('seo_controls')['index_category_archives'] ?? 'false';
531 532 printf(
532 - '<input type="checkbox" id="index_category_archives" name="' . $option_key . '[seo_controls][index_category_archives]" value="true" %s />',
533 + '<input type="checkbox" id="index_category_archives" name="' . esc_attr($option_key) . '[seo_controls][index_category_archives]" value="true" %s />',
533 534 isset($index_category_archives) && $index_category_archives == 'true' ? 'checked' : ''
534 535 );
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>');
536 + 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 537 },
537 538 $page_slug . '_seo-controls',
538 539 $SECTION_SEO_CONTROLS
539 540 );
@@ -543,9 +544,9 @@
543 544 'Add No-follow to External Links',
544 545 function() use ($option_key) {
545 546 $add_nofollow_to_external_links = Metasync::get_option('seo_controls')['add_nofollow_to_external_links'] ?? 'false';
546 547 printf(
547 - '<input type="checkbox" id="add_nofollow_to_external_links" name="' . $option_key . '[seo_controls][add_nofollow_to_external_links]" value="true" %s />',
548 + '<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 549 isset($add_nofollow_to_external_links) && $add_nofollow_to_external_links == 'true' ? 'checked' : ''
549 550 );
550 551 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 552 },
@@ -571,12 +572,12 @@
571 572 'Override Other Plugins\' Robots Tags',
572 573 function() use ($option_key) {
573 574 $override_robots_tags = Metasync::get_option('seo_controls')['override_robots_tags'] ?? 'false';
574 575 printf(
575 - '<input type="checkbox" id="override_robots_tags" name="' . $option_key . '[seo_controls][override_robots_tags]" value="true" %s />',
576 + '<input type="checkbox" id="override_robots_tags" name="' . esc_attr($option_key) . '[seo_controls][override_robots_tags]" value="true" %s />',
576 577 isset($override_robots_tags) && $override_robots_tags == 'true' ? 'checked' : ''
577 578 );
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>');
579 + 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 580 },
580 581 $page_slug . '_seo-controls',
581 582 $SECTION_SEO_CONTROLS_ADVANCED
582 583 );
@@ -599,13 +600,13 @@
599 600 'Enable Google Instant Indexing',
600 601 function() use ($option_key) {
601 602 $enable_googleinstantindex = Metasync::get_option('seo_controls')['enable_googleinstantindex'] ?? 'false';
602 603 printf(
603 - '<input type="checkbox" id="enable_googleinstantindex" name="' . $option_key . '[seo_controls][enable_googleinstantindex]" value="true" %s />',
604 + '<input type="checkbox" id="enable_googleinstantindex" name="' . esc_attr($option_key) . '[seo_controls][enable_googleinstantindex]" value="true" %s />',
604 605 isset($enable_googleinstantindex) && $enable_googleinstantindex == 'true' ? 'checked' : ''
605 606 );
606 607 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>');
608 + 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 609 },
609 610 $page_slug . '_seo-controls',
610 611 $SECTION_SEO_CONTROLS_INSTANT_INDEX
611 612 );
@@ -646,13 +647,13 @@
646 647 $enable_binginstantindex = 'true';
647 648 }
648 649
649 650 printf(
650 - '<input type="checkbox" id="enable_binginstantindex" name="' . $option_key . '[seo_controls][enable_binginstantindex]" value="true" %s %s />',
651 + '<input type="checkbox" id="enable_binginstantindex" name="' . esc_attr($option_key) . '[seo_controls][enable_binginstantindex]" value="true" %s %s />',
651 652 isset($enable_binginstantindex) && $enable_binginstantindex == 'true' ? 'checked' : '',
652 653 $has_api_key ? 'data-auto-enabled="true"' : ''
653 654 );
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>');
655 + 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 656 },
656 657 $page_slug . '_seo-controls',
657 658 'seo_controls_bing_instant_index'
658 659 );
@@ -693,9 +694,9 @@
693 694 'otto_pixel_uuid',
694 695 $whitelabel_otto_name . ' Pixel UUID',
695 696 function() use ($option_key) {
696 697 $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]"/>');
698 + echo '<input type="text" size="40" value = "'.esc_attr($value).'" name="' . esc_attr($option_key) . '[general][otto_pixel_uuid]"/>';
698 699 },
699 700 $page_slug . '_general',
700 701 $SECTION_METASYNC
701 702 );
@@ -707,12 +708,12 @@
707 708 'Disable ' . $whitelabel_otto_name . ' for Logged in Users',
708 709 function() use ($whitelabel_otto_name, $option_key) {
709 710 $otto_disable_on_loggedin = Metasync::get_option('general')['otto_disable_on_loggedin'] ?? '';
710 711 printf(
711 - '<input type="checkbox" id="otto_disable_on_loggedin" name="' . $option_key . '[general][otto_disable_on_loggedin]" value="true" %s />',
712 + '<input type="checkbox" id="otto_disable_on_loggedin" name="' . esc_attr($option_key) . '[general][otto_disable_on_loggedin]" value="true" %s />',
712 713 isset($otto_disable_on_loggedin) && $otto_disable_on_loggedin == 'true' ? 'checked' : ''
713 714 );
714 - printf('<span class="description"> This disables '.$whitelabel_otto_name.' when logged in to allow editing original page contents</span>');
715 + echo '<span class="description"> This disables '.esc_html($whitelabel_otto_name).' when logged in to allow editing original page contents</span>';
715 716 },
716 717 $page_slug . '_general',
717 718 $SECTION_METASYNC
718 719 );
@@ -723,9 +724,9 @@
723 724 'Disable ' . $whitelabel_otto_name . ' Frontend Toolbar',
724 725 function() use ($whitelabel_otto_name, $option_key) {
725 726 $otto_disable_toolbar = Metasync::get_option('general')['otto_disable_preview_button'] ?? false;
726 727 printf(
727 - '<input type="checkbox" id="otto_disable_preview_button" name="' . $option_key . '[general][otto_disable_preview_button]" value="true" %s />',
728 + '<input type="checkbox" id="otto_disable_preview_button" name="' . esc_attr($option_key) . '[general][otto_disable_preview_button]" value="true" %s />',
728 729 filter_var($otto_disable_toolbar, FILTER_VALIDATE_BOOLEAN) ? 'checked' : ''
729 730 );
730 731 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 732 },
@@ -732,8 +733,43 @@
732 733 $page_slug . '_general',
733 734 $SECTION_METASYNC
734 735 );
735 736
737 + # Global SEO title & description priority.
738 + #
739 + # Unchecked (the default) is the behaviour every site already runs: a
740 + # value the customer typed in the sidebar outranks OTTO's suggestion.
741 + # Checked hands precedence to OTTO, which is what makes a successful
742 + # OTTO deployment visible on a page that also holds a custom value.
743 + #
744 + # This only re-orders what RENDERS. Nothing is written to or deleted
745 + # from the stored post meta, so unchecking it restores the custom value
746 + # untouched. Applies to the SEO title and meta description only.
747 + add_settings_field(
748 + 'seo_priority',
749 + 'SEO Title & Description Priority',
750 + function() use ($whitelabel_otto_name, $option_key) {
751 + $seo_priority = Metasync::get_option('general')['seo_priority'] ?? '';
752 + $checked = $seo_priority === Metasync_Seo_Precedence::PRIORITY_OTTO ? 'checked' : '';
753 + $name = $option_key . '[general][seo_priority]';
754 +
755 + echo '<div class="metasync-seo-priority-control">';
756 + printf(
757 + '<label for="seo_priority"><input type="checkbox" id="seo_priority" name="%s" value="otto" %s /> <strong>Prioritize approved %s values</strong></label>',
758 + esc_attr($name),
759 + esc_attr($checked),
760 + esc_html($whitelabel_otto_name)
761 + );
762 + printf(
763 + '<p class="description" id="seo_priority_description"><strong>Off:</strong> Your custom title and description appear first; %1$s fills in only where you have no value.<br><strong>On:</strong> Approved %1$s values appear first; your custom values remain the fallback.<br><span class="metasync-seo-priority-note">Your saved values are never deleted or overwritten.</span></p>',
764 + esc_html($whitelabel_otto_name)
765 + );
766 + echo '</div>';
767 + },
768 + $page_slug . '_general',
769 + $SECTION_METASYNC
770 + );
771 +
736 772 # WP Rocket compatibility mode setting
737 773 add_settings_field(
738 774 'otto_wp_rocket_compat',
739 775 'WP Rocket Compatibility',
@@ -740,9 +776,9 @@
740 776 function() use ($whitelabel_otto_name, $option_key) {
741 777 $value = Metasync::get_option('general')['otto_wp_rocket_compat'] ?? 'auto';
742 778 $wp_rocket_active = class_exists('WP_Rocket');
743 779
744 - echo '<select id="otto_wp_rocket_compat" name="' . $option_key . '[general][otto_wp_rocket_compat]">';
780 + echo '<select id="otto_wp_rocket_compat" name="' . esc_attr($option_key) . '[general][otto_wp_rocket_compat]">';
745 781 echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
746 782 echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>';
747 783 echo '<option value="http"' . selected($value, 'http', false) . '>HTTP Mode (Safer)</option>';
748 784 echo '<option value="disable_otto"' . selected($value, 'disable_otto', false) . '>Disable ' . esc_html($whitelabel_otto_name) . ' when WP Rocket is active</option>';
@@ -770,9 +806,9 @@
770 806 function() use ($option_key) {
771 807 $value = Metasync::get_option('general')['otto_sg_optimizer_compat'] ?? 'auto';
772 808 $sg_optimizer_active = defined('SiteGround_Optimizer\VERSION') || class_exists('SiteGround_Optimizer\Parser\Parser');
773 809
774 - echo '<select id="otto_sg_optimizer_compat" name="' . $option_key . '[general][otto_sg_optimizer_compat]">';
810 + echo '<select id="otto_sg_optimizer_compat" name="' . esc_attr($option_key) . '[general][otto_sg_optimizer_compat]">';
775 811 echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
776 812 echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>';
777 813 echo '<option value="http"' . selected($value, 'http', false) . '>HTTP Mode (Safer)</option>';
778 814 echo '</select>';
@@ -802,9 +838,9 @@
802 838 'BookingPress Session Compatibility',
803 839 function() use ($option_key) {
804 840 $value = Metasync::get_option('general')['bookingpress_session_compat'] ?? 'auto';
805 841
806 - echo '<select id="bookingpress_session_compat" name="' . $option_key . '[general][bookingpress_session_compat]">';
842 + echo '<select id="bookingpress_session_compat" name="' . esc_attr($option_key) . '[general][bookingpress_session_compat]">';
807 843 echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
808 844 echo '<option value="off"' . selected($value, 'off', false) . '>Disabled</option>';
809 845 echo '</select>';
810 846
@@ -832,9 +868,9 @@
832 868 'Disable ' . $whitelabel_otto_name . ' for Bot Traffic',
833 869 function() use ($whitelabel_otto_name, $option_key) {
834 870 $otto_disable_for_bots = Metasync::get_option('general')['otto_disable_for_bots'] ?? false;
835 871 printf(
836 - '<input type="checkbox" id="otto_disable_for_bots" name="' . $option_key . '[general][otto_disable_for_bots]" value="true" %s />',
872 + '<input type="checkbox" id="otto_disable_for_bots" name="' . esc_attr($option_key) . '[general][otto_disable_for_bots]" value="true" %s />',
837 873 filter_var($otto_disable_for_bots, FILTER_VALIDATE_BOOLEAN) ? 'checked' : ''
838 874 );
839 875 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 876 },
@@ -848,9 +884,9 @@
848 884 'Bot Whitelist',
849 885 function() use ($whitelabel_otto_name, $option_key) {
850 886 $otto_bot_whitelist = Metasync::get_option('general')['otto_bot_whitelist'] ?? '';
851 887 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>',
888 + '<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 889 esc_textarea($otto_bot_whitelist)
854 890 );
855 891 echo '<p class="description">';
856 892 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 +905,9 @@
869 905 'Bot Blacklist',
870 906 function() use ($whitelabel_otto_name, $option_key) {
871 907 $otto_bot_blacklist = Metasync::get_option('general')['otto_bot_blacklist'] ?? '';
872 908 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>',
909 + '<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 910 esc_textarea($otto_bot_blacklist)
875 911 );
876 912 echo '<p class="description">';
877 913 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 +929,9 @@
893 929 printf(
894 930 '<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 931 esc_url(admin_url('admin.php?page=' . $page_slug . '-bot-statistics'))
896 932 );
897 - printf('<p class="description">View detailed bot detection statistics, breakdown by bot type, and unique bot entries with hit counts.</p>');
933 + echo '<p class="description">View detailed bot detection statistics, breakdown by bot type, and unique bot entries with hit counts.</p>';
898 934 },
899 935 $page_slug . '_general',
900 936 $SECTION_METASYNC
901 937 );
@@ -906,22 +942,20 @@
906 942 'periodic_clear_ottopage_cache',
907 943 'Clear Page Cache',
908 944 function() use ($option_key) {
909 945 $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>');
946 + echo '<select style = "width : 250px" name="' . esc_attr($option_key) . '[general][periodic_clear_ottopage_cache]" id="heading_style">';
947 + echo '<option value="24" '. selected($periodic_clear_ottopage_cache, '24', false) . '>Clear Daily</option>';
948 + echo '<option value="36" '. selected($periodic_clear_ottopage_cache, '36', false) . '>Clear Every 2 days</option>';
949 + echo '<option value="40" '. selected($periodic_clear_ottopage_cache, '40', false) . '>Clear Weekly</option>';
950 + echo '<option value="0"'.selected($periodic_clear_ottopage_cache, '0', false).'>Clear Monthly (Default)</option>';
951 + echo '</select>';
916 952
917 953 # get last cleared timestamp
918 954 $timestamp = get_option('metasync_refresh_all_caches')['pages'] ?? false;
919 955
920 956 if($timestamp > 0){
921 - printf(
922 - '<p class="descriptionValue">last Cleared : '.date('y-m-d H:i:s', $timestamp).'</p>'
923 - );
957 + echo '<p class="descriptionValue">last Cleared : '.esc_html(gmdate('y-m-d H:i:s', $timestamp)).'</p>';
924 958 }
925 959
926 960 },
927 961 $page_slug . '_otto_cache',
@@ -932,22 +966,20 @@
932 966 'periodic_clear_ottopost_cache',
933 967 'Clear Post Cache',
934 968 function() use ($option_key) {
935 969 $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>');
970 + echo '<select style = "width : 250px" name="' . esc_attr($option_key) . '[general][periodic_clear_ottopost_cache]" id="heading_style">';
971 + echo '<option value="24" '. selected($periodic_clear_ottopost_cache, '24', false) . '>Clear Daily</option>';
972 + echo '<option value="36" '. selected($periodic_clear_ottopost_cache, '36', false) . '>Clear Every 2 days</option>';
973 + echo '<option value="40" '. selected($periodic_clear_ottopost_cache, '40', false) . '>Clear Weekly</option>';
974 + echo '<option value="0"'.selected($periodic_clear_ottopost_cache, '0', false).'>Clear Monthly (Default)</option>';
975 + echo '</select>';
942 976
943 977 # get last cleared timestamp
944 978 $timestamp = get_option('metasync_refresh_all_caches')['posts'] ?? false;
945 979
946 980 if($timestamp > 0){
947 - printf(
948 - '<p class="descriptionValue">last Cleared : '.date('y-m-d H:i:s', $timestamp).'</p>'
949 - );
981 + echo '<p class="descriptionValue">last Cleared : '.esc_html(gmdate('y-m-d H:i:s', $timestamp)).'</p>';
950 982 }
951 983 },
952 984 $page_slug . '_otto_cache',
953 985 $SECTION_METASYNC
@@ -957,22 +989,20 @@
957 989 'periodic_clear_otto_cache',
958 990 'Clear all cache',
959 991 function() use ($option_key) {
960 992 $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>');
993 + echo '<select style = "width : 250px" name="' . esc_attr($option_key) . '[general][periodic_clear_otto_cache]" id="heading_style">';
994 + echo '<option value="24" '. selected($periodic_clear_otto_cache, '24', false) . '>Clear Daily</option>';
995 + echo '<option value="36" '. selected($periodic_clear_otto_cache, '36', false) . '>Clear Every 2 days</option>';
996 + echo '<option value="40" '. selected($periodic_clear_otto_cache, '40', false) . '>Clear Weekly</option>';
997 + echo '<option value="0"'.selected($periodic_clear_otto_cache, '0', false).'>Clear Monthly (Default)</option>';
998 + echo '</select>';
967 999
968 1000 # get last cleared timestamp
969 1001 $timestamp = get_option('metasync_refresh_all_caches')['general'] ?? false;
970 1002
971 1003 if($timestamp > 0){
972 - printf(
973 - '<p class="descriptionValue">last Cleared : '.date('y-m-d H:i:s', $timestamp).'</p>'
974 - );
1004 + echo '<p class="descriptionValue">last Cleared : '.esc_html(gmdate('y-m-d H:i:s', $timestamp)).'</p>';
975 1005 }
976 1006
977 1007 },
978 1008 $page_slug . '_otto_cache',
@@ -987,12 +1017,12 @@
987 1017 'Disable Common Robots Meta Box',
988 1018 function() use ($option_key) {
989 1019 $disabled = Metasync::get_option('general')['disable_common_robots_metabox'] ?? false;
990 1020 printf(
991 - '<input type="checkbox" id="disable_common_robots_metabox" name="' . $option_key . '[general][disable_common_robots_metabox]" value="1" %s />',
1021 + '<input type="checkbox" id="disable_common_robots_metabox" name="' . esc_attr($option_key) . '[general][disable_common_robots_metabox]" value="1" %s />',
992 1022 $disabled ? 'checked' : ''
993 1023 );
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'));
1024 + 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 1025 },
996 1026 $page_slug . '_general',
997 1027 $SECTION_METASYNC
998 1028 );
@@ -1002,12 +1032,12 @@
1002 1032 'Disable Advance Robots Meta Box',
1003 1033 function() use ($option_key) {
1004 1034 $disabled = Metasync::get_option('general')['disable_advance_robots_metabox'] ?? false;
1005 1035 printf(
1006 - '<input type="checkbox" id="disable_advance_robots_metabox" name="' . $option_key . '[general][disable_advance_robots_metabox]" value="1" %s />',
1036 + '<input type="checkbox" id="disable_advance_robots_metabox" name="' . esc_attr($option_key) . '[general][disable_advance_robots_metabox]" value="1" %s />',
1007 1037 $disabled ? 'checked' : ''
1008 1038 );
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'));
1039 + 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 1040 },
1011 1041 $page_slug . '_general',
1012 1042 $SECTION_METASYNC
1013 1043 );
@@ -1017,12 +1047,12 @@
1017 1047 'Disable Redirection Meta Box',
1018 1048 function() use ($option_key) {
1019 1049 $disabled = Metasync::get_option('general')['disable_redirection_metabox'] ?? false;
1020 1050 printf(
1021 - '<input type="checkbox" id="disable_redirection_metabox" name="' . $option_key . '[general][disable_redirection_metabox]" value="1" %s />',
1051 + '<input type="checkbox" id="disable_redirection_metabox" name="' . esc_attr($option_key) . '[general][disable_redirection_metabox]" value="1" %s />',
1022 1052 $disabled ? 'checked' : ''
1023 1053 );
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>');
1054 + 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 1055 },
1026 1056 $page_slug . '_general',
1027 1057 $SECTION_METASYNC
1028 1058 );
@@ -1032,12 +1062,12 @@
1032 1062 'Disable Canonical Meta Box',
1033 1063 function() use ($option_key) {
1034 1064 $disabled = Metasync::get_option('general')['disable_canonical_metabox'] ?? false;
1035 1065 printf(
1036 - '<input type="checkbox" id="disable_canonical_metabox" name="' . $option_key . '[general][disable_canonical_metabox]" value="1" %s />',
1066 + '<input type="checkbox" id="disable_canonical_metabox" name="' . esc_attr($option_key) . '[general][disable_canonical_metabox]" value="1" %s />',
1037 1067 $disabled ? 'checked' : ''
1038 1068 );
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'));
1069 + 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 1070 },
1041 1071 $page_slug . '_general',
1042 1072 $SECTION_METASYNC
1043 1073 );
@@ -1047,12 +1077,12 @@
1047 1077 'Disable Social Media & Open Graph Meta Box',
1048 1078 function() use ($option_key) {
1049 1079 $disabled = Metasync::get_option('general')['disable_social_opengraph_metabox'] ?? false;
1050 1080 printf(
1051 - '<input type="checkbox" id="disable_social_opengraph_metabox" name="' . $option_key . '[general][disable_social_opengraph_metabox]" value="1" %s />',
1081 + '<input type="checkbox" id="disable_social_opengraph_metabox" name="' . esc_attr($option_key) . '[general][disable_social_opengraph_metabox]" value="1" %s />',
1052 1082 $disabled ? 'checked' : ''
1053 1083 );
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'));
1084 + 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 1085 },
1056 1086 $page_slug . '_general',
1057 1087 $SECTION_METASYNC
1058 1088 );
@@ -1062,12 +1092,12 @@
1062 1092 'Disable Schema Markup Meta Box',
1063 1093 function() use ($option_key) {
1064 1094 $disabled = Metasync::get_option('general')['disable_schema_markup_metabox'] ?? false;
1065 1095 printf(
1066 - '<input type="checkbox" id="disable_schema_markup_metabox" name="' . $option_key . '[general][disable_schema_markup_metabox]" value="1" %s />',
1096 + '<input type="checkbox" id="disable_schema_markup_metabox" name="' . esc_attr($option_key) . '[general][disable_schema_markup_metabox]" value="1" %s />',
1067 1097 $disabled ? 'checked' : ''
1068 1098 );
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'));
1099 + 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 1100 },
1071 1101 $page_slug . '_general',
1072 1102 $SECTION_METASYNC
1073 1103 );
@@ -1072,17 +1102,32 @@
1072 1102 $SECTION_METASYNC
1073 1103 );
1074 1104
1075 1105 add_settings_field(
1106 + 'disable_language_alternates_metabox',
1107 + 'Disable Language Alternates Meta Box',
1108 + function() use ($option_key) {
1109 + $disabled = Metasync::get_option('general')['disable_language_alternates_metabox'] ?? false;
1110 + printf(
1111 + '<input type="checkbox" id="disable_language_alternates_metabox" name="' . esc_attr($option_key) . '[general][disable_language_alternates_metabox]" value="1" %s />',
1112 + $disabled ? 'checked' : ''
1113 + );
1114 + 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;');
1115 + },
1116 + $page_slug . '_general',
1117 + $SECTION_METASYNC
1118 + );
1119 +
1120 + add_settings_field(
1076 1121 'disable_seo_metabox',
1077 1122 'Disable SEO Meta Box',
1078 1123 function() use ($option_key) {
1079 1124 $disabled = Metasync::get_option('general')['disable_seo_metabox'] ?? false;
1080 1125 printf(
1081 - '<input type="checkbox" id="disable_seo_metabox" name="' . $option_key . '[general][disable_seo_metabox]" value="1" %s />',
1126 + '<input type="checkbox" id="disable_seo_metabox" name="' . esc_attr($option_key) . '[general][disable_seo_metabox]" value="1" %s />',
1082 1127 $disabled ? 'checked' : ''
1083 1128 );
1084 - printf('<span class="description"> Hide the SEO Title & Meta Description meta box on post/page edit screens</span>');
1129 + echo '<span class="description"> Hide the SEO Title & Meta Description meta box on post/page edit screens</span>';
1085 1130 },
1086 1131 $page_slug . '_general',
1087 1132 $SECTION_METASYNC
1088 1133 );
@@ -1092,9 +1137,9 @@
1092 1137 'Open External Links in New Tab/Window',
1093 1138 function() use ($option_key) {
1094 1139 $enabled = Metasync::get_option('general')['open_external_links'] ?? false;
1095 1140 printf(
1096 - '<input type="checkbox" id="open_external_links" name="' . $option_key . '[general][open_external_links]" value="1" %s />',
1141 + '<input type="checkbox" id="open_external_links" name="' . esc_attr($option_key) . '[general][open_external_links]" value="1" %s />',
1097 1142 $enabled ? 'checked' : ''
1098 1143 );
1099 1144 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 1145 },
@@ -1110,11 +1155,11 @@
1110 1155 $current_permalink_structure = get_option('permalink_structure');
1111 1156 $current_rewrite_rules = get_option('rewrite_rules');
1112 1157 // Check if the current permalink structure is set to "Plain"
1113 1158 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>');
1159 + 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 1160 } else {
1116 - printf('<span class="description" style="color:#008000;opacity:1;">Permalink is Okay </span>');
1161 + echo '<span class="description" style="color:#008000;opacity:1;">Permalink is Okay </span>';
1117 1162 }
1118 1163 },
1119 1164 $page_slug . '_general',
1120 1165 $SECTION_METASYNC
@@ -1126,9 +1171,9 @@
1126 1171 'Hide Dashboard',
1127 1172 function() use ($option_key) {
1128 1173 $hide_dashboard = Metasync::get_option('general')['hide_dashboard_framework'] ?? '';
1129 1174 printf(
1130 - '<input type="checkbox" id="hide_dashboard_framework" name="' . $option_key . '[general][hide_dashboard_framework]" value="true" %s />',
1175 + '<input type="checkbox" id="hide_dashboard_framework" name="' . esc_attr($option_key) . '[general][hide_dashboard_framework]" value="true" %s />',
1131 1176 isset($hide_dashboard) && $hide_dashboard == 'true' ? 'checked' : ''
1132 1177 );
1133 1178 printf('<span class="description"> Hide the %s dashboard</span>', esc_html(Metasync_Settings_Registration::instance()->get_effective_menu_title()));
1134 1179 },
@@ -1144,9 +1189,9 @@
1144 1189 'Show ' . $this->get_effective_menu_title() . ' Status in Admin Bar',
1145 1190 function() use ($option_key) {
1146 1191 $show_admin_bar = Metasync::get_option('general')['show_admin_bar_status'] ?? true;
1147 1192 printf(
1148 - '<input type="checkbox" id="show_admin_bar_status" name="' . $option_key . '[general][show_admin_bar_status]" value="true" %s />',
1193 + '<input type="checkbox" id="show_admin_bar_status" name="' . esc_attr($option_key) . '[general][show_admin_bar_status]" value="true" %s />',
1149 1194 $show_admin_bar ? 'checked' : ''
1150 1195 );
1151 1196 printf('<span class="description">Show the %s status indicator in the WordPress admin bar.</span>', esc_html(Metasync::get_effective_plugin_name()));
1152 1197 },
@@ -1153,24 +1198,8 @@
1153 1198 $page_slug . '_general',
1154 1199 $SECTION_METASYNC
1155 1200 );
1156 1201
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 1202 # Add User Role Sync Setting
1174 1203 add_settings_field(
1175 1204 'content_genius_sync_roles',
1176 1205 'Content Genius User Roles to Sync',
@@ -1272,9 +1301,9 @@
1272 1301 echo '<select id="default_page_builder" name="' . esc_attr($option_key) . '[general][default_page_builder]" style="min-width: 300px;">';
1273 1302
1274 1303 foreach ($builders as $key => $builder) {
1275 1304 $is_detected = $builder['detected'];
1276 - $label = esc_html($builder['label']);
1305 + $label = $builder['label'];
1277 1306 if ($is_detected && $key !== 'gutenberg') {
1278 1307 $label .= ' — Detected';
1279 1308 } elseif (!$is_detected) {
1280 1309 $label .= ' — Not Detected';
@@ -1286,9 +1315,9 @@
1286 1315 '<option value="%s" %s%s>%s</option>',
1287 1316 esc_attr($key),
1288 1317 selected(empty($current) ? 'gutenberg' : $current, $key, false),
1289 1318 $is_detected ? '' : ' disabled',
1290 - $label
1319 + esc_html($label)
1291 1320 );
1292 1321 }
1293 1322
1294 1323 echo '</select>';
@@ -1337,9 +1366,9 @@
1337 1366 printf(
1338 1367 '<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 1368 esc_url(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-import-external'))
1340 1369 );
1341 - printf('<p class="description">Import settings and data from other SEO plugins (Yoast, Rank Math, AIOSEO, etc).</p>');
1370 + echo '<p class="description">Import settings and data from other SEO plugins (Yoast, Rank Math, AIOSEO, etc).</p>';
1342 1371 },
1343 1372 $page_slug . '_general',
1344 1373 $SECTION_METASYNC
1345 1374 );
@@ -1354,10 +1383,10 @@
1354 1383 'white_label_plugin_name',
1355 1384 '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 1385 function() use ($option_key) {
1357 1386 $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>');
1387 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_name]" value="' . esc_attr($value) . '" maxlength="18" />';
1388 + 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 1389 },
1361 1390 $page_slug . '_branding',
1362 1391 $SECTION_METASYNC
1363 1392 );
@@ -1366,9 +1395,9 @@
1366 1395 'whitelabel_otto_name',
1367 1396 '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 1397 function() use ($option_key) {
1369 1398 $value = Metasync::get_option('general')['whitelabel_otto_name'] ?? '';
1370 - printf('<input type="text" name="' . $option_key . '[general][whitelabel_otto_name]" value="' . esc_attr($value) . '" />');
1399 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][whitelabel_otto_name]" value="' . esc_attr($value) . '" />';
1371 1400 $example_name = !empty($value) ? $value : 'OTTO';
1372 1401 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 1402 },
1374 1403 $page_slug . '_branding',
@@ -1380,9 +1409,9 @@
1380 1409 '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 1410 function() use ($option_key) {
1382 1411 $whitelabel_settings = Metasync::get_whitelabel_settings();
1383 1412 $value = $whitelabel_settings['logo_light'] ?? '';
1384 - printf('<input type="url" name="' . $option_key . '[whitelabel][logo_light]" value="' . esc_attr($value) . '" size="60" />');
1413 + echo '<input type="url" name="' . esc_attr($option_key) . '[whitelabel][logo_light]" value="' . esc_attr($value) . '" size="60" />';
1385 1414 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 1415 },
1387 1416 $page_slug . '_branding',
1388 1417 $SECTION_METASYNC
@@ -1393,9 +1422,9 @@
1393 1422 '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 1423 function() use ($option_key) {
1395 1424 $whitelabel_settings = Metasync::get_whitelabel_settings();
1396 1425 $value = $whitelabel_settings['logo_dark'] ?? '';
1397 - printf('<input type="url" name="' . $option_key . '[whitelabel][logo_dark]" value="' . esc_attr($value) . '" size="60" />');
1426 + echo '<input type="url" name="' . esc_attr($option_key) . '[whitelabel][logo_dark]" value="' . esc_attr($value) . '" size="60" />';
1398 1427 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 1428 },
1400 1429 $page_slug . '_branding',
1401 1430 $SECTION_METASYNC
@@ -1406,10 +1435,10 @@
1406 1435 '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 1436 function() use ($option_key) {
1408 1437 $whitelabel_settings = Metasync::get_whitelabel_settings();
1409 1438 $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>');
1439 + echo '<input type="url" name="' . esc_attr($option_key) . '[whitelabel][domain]" value="' . esc_attr($value) . '" size="60" />';
1440 + echo '<p class="description">Enter your whitelabel dashboard URL (e.g., https://yourdashboard.com). Used for branding purposes.</p>';
1412 1441 },
1413 1442 $page_slug . '_branding',
1414 1443 $SECTION_METASYNC
1415 1444 );
@@ -1417,9 +1446,9 @@
1417 1446 'white_label_plugin_description',
1418 1447 'Plugin Description',
1419 1448 function() use ($option_key) {
1420 1449 $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) . '" />');
1450 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_description]" value="' . esc_attr($value) . '" />';
1422 1451 },
1423 1452 $page_slug . '_branding',
1424 1453 $SECTION_METASYNC
1425 1454 );
@@ -1428,9 +1457,9 @@
1428 1457 'white_label_plugin_author',
1429 1458 'Author',
1430 1459 function() use ($option_key) {
1431 1460 $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) . '" />');
1461 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_author]" value="' . esc_attr($value) . '" />';
1433 1462 },
1434 1463 $page_slug . '_branding',
1435 1464 $SECTION_METASYNC
1436 1465 );
@@ -1439,9 +1468,9 @@
1439 1468 'white_label_plugin_author_uri',
1440 1469 'Author URI',
1441 1470 function() use ($option_key) {
1442 1471 $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) . '" />');
1472 + # printf('<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_author_uri]" value="' . esc_attr($value) . '" />');
1444 1473 # Fixed printf usage
1445 1474 printf('<input type="text" name="%s" value="%s" />', esc_attr($option_key . '[general][white_label_plugin_author_uri]'), esc_attr($value) );
1446 1475
1447 1476 },
@@ -1452,9 +1481,9 @@
1452 1481 'white_label_plugin_uri',
1453 1482 'Plugin URI',
1454 1483 function() use ($option_key) {
1455 1484 $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) . '" />');
1485 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_uri]" value="' . esc_attr($value) . '" />';
1457 1486 },
1458 1487 $page_slug . '_branding',
1459 1488 $SECTION_METASYNC
1460 1489 );
@@ -1468,10 +1497,10 @@
1468 1497 'white_label_plugin_menu_slug',
1469 1498 'Menu Slug',
1470 1499 function() use ($option_key) {
1471 1500 $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>');
1501 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_menu_slug]" value="' . esc_attr($value) . '" placeholder="hip-advanced-seo" />';
1502 + 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 1503 },
1475 1504 $page_slug . '_branding',
1476 1505 $SECTION_METASYNC
1477 1506 );
@@ -1479,9 +1508,9 @@
1479 1508 'white_label_plugin_menu_icon',
1480 1509 'Menu Icon',
1481 1510 function() use ($option_key) {
1482 1511 $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) . '" />';
1512 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_menu_icon]" value="' . esc_attr($value) . '" />';
1484 1513 },
1485 1514 $page_slug . '_branding',
1486 1515 $SECTION_METASYNC
1487 1516 );
@@ -1496,22 +1525,22 @@
1496 1525
1497 1526 // Output radio button for Default Style.css active
1498 1527
1499 1528 printf(
1500 - '<input type="radio" id="enable_default" name="' . $option_key . '[general][enabled_plugin_css]" value="default" %s />',
1529 + '<input type="radio" id="enable_default" name="' . esc_attr($option_key) . '[general][enabled_plugin_css]" value="default" %s />',
1501 1530 ($enabled_plugin_css == 'default'||$enabled_plugin_css =='') ? 'checked' : ''
1502 1531 );
1503 - printf('<label for="enable_default">Default</label><br>');
1532 + echo '<label for="enable_default">Default</label><br>';
1504 1533
1505 1534
1506 1535 // Output radio button for Metasync Style
1507 1536 printf(
1508 - '<input type="radio" id="enable_metasync" name="' . $option_key . '[general][enabled_plugin_css]" value="metasync" %s />',
1537 + '<input type="radio" id="enable_metasync" name="' . esc_attr($option_key) . '[general][enabled_plugin_css]" value="metasync" %s />',
1509 1538 ($enabled_plugin_css == 'metasync') ? 'checked' : ''
1510 1539 );
1511 - printf('<label for="enable_metasync">Metasync Style</label>');
1540 + echo '<label for="enable_metasync">Metasync Style</label>';
1512 1541
1513 - printf('<p class="description"> Choose the default page Style Sheet: Default or MetaSync.</p>');
1542 + echo '<p class="description"> Choose the default page Style Sheet: Default or MetaSync.</p>';
1514 1543 },
1515 1544 $page_slug . '_branding',
1516 1545 $SECTION_METASYNC
1517 1546 );
@@ -1521,12 +1550,12 @@
1521 1550 'enabled_elementor_plugin_css',
1522 1551 'Choose Elementor Font Style',
1523 1552 function() use ($option_key) {
1524 1553 $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>');
1554 + echo '<select name="' . esc_attr($option_key) . '[general][enabled_elementor_plugin_css]" id="heading_style">';
1555 + echo '<option value="default"'.selected($enabled_elementor_plugin_css, 'default', false).'>Default</option>';
1556 + echo '<option value="custom" '. selected($enabled_elementor_plugin_css, 'custom', false) . '>Custom</option>';
1557 + echo '</select>';
1529 1558 },
1530 1559 $page_slug . '_branding',
1531 1560 $SECTION_METASYNC
1532 1561 );
@@ -1534,9 +1563,9 @@
1534 1563 'enabled_elementor_plugin_css_color',
1535 1564 'Choose Elementor Font Color',
1536 1565 function() use ($option_key) {
1537 1566 $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.'">');
1567 + 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 1568 },
1540 1569 $page_slug . '_branding',
1541 1570 $SECTION_METASYNC
1542 1571 );
@@ -1547,10 +1576,10 @@
1547 1576 function() use ($option_key) {
1548 1577 // Decrypted for display — this field only renders after the
1549 1578 // whitelabel password gate has been passed.
1550 1579 $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>');
1580 + echo '<input type="password" name="' . esc_attr($option_key) . '[whitelabel][settings_password]" value="' . esc_attr($value) . '" size="30" autocomplete="new-password" />';
1581 + echo '<p class="description">Set a custom password to protect the branding settings section.</p>';
1553 1582 },
1554 1583 $page_slug . '_branding',
1555 1584 $SECTION_METASYNC
1556 1585 );
@@ -1561,10 +1590,10 @@
1561 1590 function() use ($option_key) {
1562 1591 $whitelabel_settings = Metasync::get_whitelabel_settings();
1563 1592 $value = $whitelabel_settings['recovery_email'] ?? '';
1564 1593 $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>');
1594 + printf('<input type="email" name="' . esc_attr($option_key) . '[whitelabel][recovery_email]" value="' . esc_attr($value) . '" size="30" autocomplete="email" %s />', $has_password ? 'required' : '');
1595 + echo '<p class="description">Email address to receive password recovery. <strong>Required when password is set.</strong></p>';
1567 1596 },
1568 1597 $page_slug . '_branding',
1569 1598 $SECTION_METASYNC
1570 1599 );
@@ -1594,11 +1623,11 @@
1594 1623 <td><input type="text" name="%s[whitelabel][quick_links][%d][label]" value="%s" placeholder="Link label" style="width:100%%" /></td>
1595 1624 <td><input type="url" name="%s[whitelabel][quick_links][%d][url]" value="%s" placeholder="https://…" style="width:100%%" /></td>
1596 1625 <td style="text-align:center"><input type="checkbox" name="%s[whitelabel][quick_links][%d][external]" value="1" %s /></td>
1597 1626 </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)
1627 + esc_attr($option_key), esc_attr($i), esc_attr($label),
1628 + esc_attr($option_key), esc_attr($i), esc_attr($url),
1629 + esc_attr($option_key), esc_attr($i), checked($external, true, false)
1601 1630 );
1602 1631 }
1603 1632 echo '</tbody></table>';
1604 1633 },
@@ -1625,9 +1654,9 @@
1625 1654 'Organization Name',
1626 1655 function() use ($option_key) {
1627 1656 $schema = Metasync::get_option('schema') ?? [];
1628 1657 $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));
1658 + printf('<input type="text" name="' . esc_attr($option_key) . '[schema][org_name]" value="%s" size="40" placeholder="Your organization name" />', esc_attr($value));
1630 1659 },
1631 1660 $schema_page,
1632 1661 'metasync_schema_org'
1633 1662 );
@@ -1637,9 +1666,9 @@
1637 1666 'Organization URL',
1638 1667 function() use ($option_key) {
1639 1668 $schema = Metasync::get_option('schema') ?? [];
1640 1669 $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));
1670 + printf('<input type="url" name="' . esc_attr($option_key) . '[schema][org_url]" value="%s" size="40" placeholder="https://example.com" />', esc_attr($value));
1642 1671 },
1643 1672 $schema_page,
1644 1673 'metasync_schema_org'
1645 1674 );
@@ -1649,9 +1678,9 @@
1649 1678 '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 1679 function() use ($option_key) {
1651 1680 $schema = Metasync::get_option('schema') ?? [];
1652 1681 $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));
1682 + 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 1683 },
1655 1684 $schema_page,
1656 1685 'metasync_schema_org'
1657 1686 );
@@ -1661,9 +1690,9 @@
1661 1690 'Contact Telephone',
1662 1691 function() use ($option_key) {
1663 1692 $schema = Metasync::get_option('schema') ?? [];
1664 1693 $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));
1694 + 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 1695 },
1667 1696 $schema_page,
1668 1697 'metasync_schema_org'
1669 1698 );
@@ -1673,9 +1702,9 @@
1673 1702 '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 1703 function() use ($option_key) {
1675 1704 $schema = Metasync::get_option('schema') ?? [];
1676 1705 $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));
1706 + printf('<input type="text" name="' . esc_attr($option_key) . '[schema][org_contact_type]" value="%s" size="30" placeholder="customer support" />', esc_attr($value));
1678 1707 },
1679 1708 $schema_page,
1680 1709 'metasync_schema_org'
1681 1710 );
@@ -1685,9 +1714,9 @@
1685 1714 '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 1715 function() use ($option_key) {
1687 1716 $schema = Metasync::get_option('schema') ?? [];
1688 1717 $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));
1718 + 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 1719 echo '<p class="description">Enter one social profile URL per line.</p>';
1691 1720 },
1692 1721 $schema_page,
1693 1722 'metasync_schema_org'
@@ -1707,9 +1736,9 @@
1707 1736 'WebSite Name',
1708 1737 function() use ($option_key) {
1709 1738 $schema = Metasync::get_option('schema') ?? [];
1710 1739 $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));
1740 + printf('<input type="text" name="' . esc_attr($option_key) . '[schema][website_name]" value="%s" size="40" placeholder="Your website name" />', esc_attr($value));
1712 1741 },
1713 1742 $schema_page,
1714 1743 'metasync_schema_website'
1715 1744 );
@@ -1719,9 +1748,9 @@
1719 1748 'WebSite URL',
1720 1749 function() use ($option_key) {
1721 1750 $schema = Metasync::get_option('schema') ?? [];
1722 1751 $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));
1752 + printf('<input type="url" name="' . esc_attr($option_key) . '[schema][website_url]" value="%s" size="40" placeholder="https://example.com" />', esc_attr($value));
1724 1753 },
1725 1754 $schema_page,
1726 1755 'metasync_schema_website'
1727 1756 );
@@ -1732,9 +1761,9 @@
1732 1761 function() use ($option_key) {
1733 1762 $schema = Metasync::get_option('schema') ?? [];
1734 1763 $value = $schema['website_searchbox'] ?? false;
1735 1764 printf(
1736 - '<input type="checkbox" name="' . $option_key . '[schema][website_searchbox]" value="1" %s />',
1765 + '<input type="checkbox" name="' . esc_attr($option_key) . '[schema][website_searchbox]" value="1" %s />',
1737 1766 checked($value, true, false)
1738 1767 );
1739 1768 echo '<span class="description">Adds SearchAction to WebSite schema for Google Sitelinks Searchbox.</span>';
1740 1769 },
@@ -1757,9 +1786,9 @@
1757 1786 function() use ($option_key) {
1758 1787 $schema = Metasync::get_option('schema') ?? [];
1759 1788 $value = $schema['override_woocommerce_schema'] ?? false;
1760 1789 printf(
1761 - '<input type="checkbox" name="' . $option_key . '[schema][override_woocommerce_schema]" value="1" %s />',
1790 + '<input type="checkbox" name="' . esc_attr($option_key) . '[schema][override_woocommerce_schema]" value="1" %s />',
1762 1791 checked($value, true, false)
1763 1792 );
1764 1793 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 1794 },
@@ -1813,9 +1842,9 @@
1813 1842 'baidu_site_verification',
1814 1843 '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 1844 function() use ($option_key) {
1816 1845 $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 ) );
1846 + 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 1847 },
1819 1848 $page_slug . '_searchengines-verification',
1820 1849 $SECTION_SEARCHENGINE
1821 1850 );
@@ -1824,9 +1853,9 @@
1824 1853 'alexa_site_verification',
1825 1854 '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 1855 function() use ($option_key) {
1827 1856 $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 ) );
1857 + 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 1858 },
1830 1859 $page_slug . '_searchengines-verification',
1831 1860 $SECTION_SEARCHENGINE
1832 1861 );
@@ -1835,9 +1864,9 @@
1835 1864 'norton_save_site_verification',
1836 1865 '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 1866 function() use ($option_key) {
1838 1867 $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 ) );
1868 + 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 1869 },
1841 1870 $page_slug . '_searchengines-verification',
1842 1871 $SECTION_SEARCHENGINE
1843 1872 );
@@ -1896,121 +1925,14 @@
1896 1925 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 1926 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 1927
1899 1928 // ----------------------------------------------------------------
1900 - // Breadcrumbs page
1929 + // Breadcrumbs page — intentionally registers no Settings API fields.
1930 + // The dedicated Breadcrumbs admin page renders the richer controls
1931 + // directly via Metasync_Settings_Fields::render_breadcrumbs_section()
1932 + // and writes the same metasync_options[breadcrumbs] keys.
1901 1933 // ----------------------------------------------------------------
1902 - add_settings_section(
1903 - $SECTION_BREADCRUMBS,
1904 - '',
1905 - function() {},
1906 - $page_slug . '_breadcrumbs'
1907 - );
1908 1934
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 1935 # Open Graph / Article meta toggles — surface each new tag family as its
2014 1936 # own opt-out checkbox on the main Settings page (Open Graph accordion).
2015 1937 add_settings_field(
2016 1938 'og_image_dimensions',
@@ -2160,10 +2082,10 @@
2160 2082 * @return array Sanitized settings
2161 2083 */
2162 2084 public function sanitize($input)
2163 2085 {
2164 - $submitted_generation = isset($_POST['metasync_pw_reset_generation'])
2165 - ? absint($_POST['metasync_pw_reset_generation'])
2086 + $submitted_generation = isset($_POST['metasync_pw_reset_generation']) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2087 + ? absint($_POST['metasync_pw_reset_generation']) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2166 2088 : null;
2167 2089 // Legacy/stale forms that include a password but no reset generation
2168 2090 // must never be allowed to overwrite a newer recovery password.
2169 2091 if (!self::$allow_recovery_password_write
@@ -2207,15 +2129,15 @@
2207 2129
2208 2130 # Determine which tab is being submitted (same logic as AJAX handler)
2209 2131 # Check POST first (from AJAX), then GET (from form action URL)
2210 2132 $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'])) {
2133 + if (isset($_POST['active_tab'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2134 + $active_tab = sanitize_text_field(wp_unslash($_POST['active_tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2135 + } elseif (isset($_GET['tab'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- sanitize callback, the caller already verified the nonce
2136 + $active_tab = sanitize_text_field(wp_unslash($_GET['tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- sanitize callback, the caller already verified the nonce
2137 + } elseif (isset($_POST['_wp_http_referer'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2216 2138 # Parse referer URL to get tab parameter
2217 - $referer = wp_parse_url($_POST['_wp_http_referer']);
2139 + $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 2140 if (isset($referer['query'])) {
2219 2141 parse_str($referer['query'], $referer_params);
2220 2142 if (isset($referer_params['tab'])) {
2221 2143 $active_tab = sanitize_text_field($referer_params['tab']);
@@ -2237,10 +2159,15 @@
2237 2159 'disable_redirection_metabox',
2238 2160 'disable_canonical_metabox',
2239 2161 'disable_social_opengraph_metabox',
2240 2162 'disable_schema_markup_metabox',
2163 + 'disable_language_alternates_metabox',
2241 2164 'disable_seo_metabox',
2242 - 'open_external_links'
2165 + 'open_external_links',
2166 + // Consent switch for writing into other SEO plugins. Absent from
2167 + // the POST means unchecked, which must resolve to off — a
2168 + // permanent write into another plugin's data is never inferred.
2169 + Metasync_Seo_Backup::CONSENT_OPTION_KEY
2243 2170 ];
2244 2171
2245 2172 foreach ($checkbox_fields as $field) {
2246 2173 if (isset($input['general'][$field])) {
@@ -2251,8 +2178,21 @@
2251 2178 // back through the layered merge.
2252 2179 $new_input['general'][$field] = false;
2253 2180 }
2254 2181 }
2182 +
2183 + // SEO priority is a checkbox whose stored value is a string, so it
2184 + // cannot ride the boolean loop above. Only the exact opt-in value
2185 + // is accepted; unchecked (absent) or anything else resolves to
2186 + // custom-first. Writing an explicit value rather than leaving the
2187 + // key absent matters for the same reason the loop above writes
2188 + // false: the layered merge would otherwise let the stored 'otto'
2189 + // leak back and the box could never be unticked.
2190 + $new_input['general']['seo_priority'] =
2191 + (isset($input['general']['seo_priority'])
2192 + && $input['general']['seo_priority'] === Metasync_Seo_Precedence::PRIORITY_OTTO)
2193 + ? Metasync_Seo_Precedence::PRIORITY_OTTO
2194 + : 'custom';
2255 2195 }
2256 2196
2257 2197 // Site Verification Settings
2258 2198 if (isset($input['searchengines']['bing_site_verification'])) {
@@ -2354,13 +2294,28 @@
2354 2294 }
2355 2295
2356 2296 // Breadcrumbs Settings
2357 2297 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']);
2298 + // Checkbox fields are only written when the submitting form
2299 + // actually rendered them. render_breadcrumbs_section() pairs every
2300 + // checkbox with a hidden companion input, so the key is always
2301 + // present there and unchecking works. A missing key means the form
2302 + // does not manage that field, so the stored value is preserved
2303 + // instead of being silently reset to false.
2304 + foreach (array('enabled', 'show_current_page', 'disable_schema') as $breadcrumb_flag) {
2305 + if (isset($input['breadcrumbs'][$breadcrumb_flag])) {
2306 + $new_input['breadcrumbs'][$breadcrumb_flag] = !empty($input['breadcrumbs'][$breadcrumb_flag]);
2307 + }
2308 + }
2309 + // The Custom radio always renders and can submit an empty value
2310 + // when selected without typing. An empty separator would render
2311 + // a blank gap between breadcrumb links, so it is dropped and the
2312 + // stored value survives, exactly like an absent key.
2361 2313 if (isset($input['breadcrumbs']['separator'])) {
2362 - $new_input['breadcrumbs']['separator'] = sanitize_text_field($input['breadcrumbs']['separator']);
2314 + $breadcrumb_separator = sanitize_text_field($input['breadcrumbs']['separator']);
2315 + if ('' !== $breadcrumb_separator) {
2316 + $new_input['breadcrumbs']['separator'] = $breadcrumb_separator;
2317 + }
2363 2318 }
2364 2319 if (isset($input['breadcrumbs']['home_label'])) {
2365 2320 $new_input['breadcrumbs']['home_label'] = sanitize_text_field($input['breadcrumbs']['home_label']);
2366 2321 }
@@ -2722,8 +2677,26 @@
2722 2677 do_action('metasync_trigger_immediate_heartbeat', 'Whitelabel domain change - ' . $context);
2723 2678 });
2724 2679 }
2725 2680
2681 + // Headless settings are never written through this form. They are owned by
2682 + // Metasync_Headless_Config and saved by their own handler, so anything
2683 + // arriving here under that key is a stale form or a hand-built request.
2684 + // The merge below would let it write over the stored block key by key —
2685 + // switching the mode off that way removes the GraphQL field and breaks
2686 + // every persisted query a headless frontend holds, so the key is dropped
2687 + // rather than merged.
2688 + //
2689 + // Except when the owner is the one writing. register_setting() makes this
2690 + // method a `sanitize_option_metasync_options` filter, and WordPress runs
2691 + // it on every update_option() call for the option, not only on Settings
2692 + // API form posts — so dropping the key unconditionally also dropped
2693 + // Metasync_Headless_Config's own writes, and the Headless Mode block
2694 + // could never save anything on a real request.
2695 + if (!Metasync_Headless_Config::is_writing()) {
2696 + unset($input[Metasync_Headless_Config::SETTINGS_KEY]);
2697 + }
2698 +
2726 2699 // Keep the fully sanitized/encrypted white-label values. The layered
2727 2700 // merge below already lets them win; mirroring them into the raw
2728 2701 // layer also excludes stray raw whitelabel keys from the saved option.
2729 2702 if (isset($input['whitelabel'], $new_input['whitelabel'])) {
@@ -2754,9 +2727,10 @@
2754 2727 * Moved from Metasync_Admin – the admin class now delegates here.
2755 2728 */
2756 2729 public function meta_sync_save_settings()
2757 2730 {
2758 - if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce($_POST['meta_sync_nonce'], 'meta_sync_general_setting_nonce')) {
2731 + $meta_sync_nonce = isset($_POST['meta_sync_nonce']) ? sanitize_text_field(wp_unslash($_POST['meta_sync_nonce'])) : '';
2732 + if (!wp_verify_nonce($meta_sync_nonce, 'meta_sync_general_setting_nonce')) {
2759 2733 wp_send_json_error(array('message' => 'Invalid nonce'));
2760 2734 return;
2761 2735 }
2762 2736
@@ -2803,9 +2777,9 @@
2803 2777 $url_fields = [
2804 2778 'white_label_plugin_author_uri', 'white_label_plugin_uri'
2805 2779 ];
2806 2780
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'];
2781 + $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 2782
2809 2783 $url_fields = ['white_label_plugin_author_uri', 'white_label_plugin_uri'];
2810 2784
2811 2785 $metasync_options = Metasync::get_option();
@@ -2817,9 +2791,9 @@
2817 2791 }
2818 2792
2819 2793 $validation_errors = [];
2820 2794
2821 - $active_tab = isset($_POST['active_tab']) ? sanitize_text_field($_POST['active_tab']) : 'general';
2795 + $active_tab = isset($_POST['active_tab']) ? sanitize_text_field(wp_unslash($_POST['active_tab'])) : 'general';
2822 2796 $general_tab_submitted = ($active_tab === 'general');
2823 2797 $whitelabel_tab_submitted = ($active_tab === 'whitelabel');
2824 2798
2825 2799 // Whitelabel field validation issues are collected per-field and reported back
@@ -2829,9 +2803,10 @@
2829 2803 foreach ($text_fields as $field) {
2830 2804
2831 2805 if (isset($_POST['metasync_options']['general'][$field])) {
2832 2806
2833 - $value = trim($_POST['metasync_options']['general'][$field]);
2807 + // 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.
2808 + $value = trim(wp_unslash($_POST['metasync_options']['general'][$field]));
2834 2809
2835 2810 // searchatlas_api_key must be clearable to support key removal
2836 2811 $clearable_fields = [
2837 2812 'searchatlas_api_key',
@@ -2868,9 +2843,9 @@
2868 2843 } elseif (filter_var($value, FILTER_VALIDATE_URL)) {
2869 2844
2870 2845 $image_extensions = ['png', 'svg'];
2871 2846
2872 - $path = parse_url($value, PHP_URL_PATH);
2847 + $path = wp_parse_url($value, PHP_URL_PATH);
2873 2848 $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
2874 2849
2875 2850 if (in_array($extension, $image_extensions, true)) {
2876 2851 $metasync_options['general'][$field] = esc_url_raw($value);
@@ -2884,9 +2859,9 @@
2884 2859 // Self-correcting slug: a URL-shaped value is reduced to a valid WP
2885 2860 // menu slug so it can never break sidebar link generation.
2886 2861 $metasync_options['general'][$field] = sanitize_title($value);
2887 2862 } else {
2888 - $metasync_options['general'][$field] = sanitize_text_field($_POST['metasync_options']['general'][$field]);
2863 + $metasync_options['general'][$field] = sanitize_text_field(wp_unslash($_POST['metasync_options']['general'][$field]));
2889 2864 }
2890 2865 }
2891 2866 }
2892 2867
@@ -2893,9 +2868,9 @@
2893 2868 $textarea_fields = ['otto_bot_whitelist', 'otto_bot_blacklist'];
2894 2869 if ($general_tab_submitted) {
2895 2870 foreach ($textarea_fields as $field) {
2896 2871 if (isset($_POST['metasync_options']['general'][$field])) {
2897 - $metasync_options['general'][$field] = sanitize_textarea_field($_POST['metasync_options']['general'][$field]);
2872 + $metasync_options['general'][$field] = sanitize_textarea_field(wp_unslash($_POST['metasync_options']['general'][$field]));
2898 2873 } else {
2899 2874 $metasync_options['general'][$field] = '';
2900 2875 }
2901 2876 }
@@ -2903,13 +2878,23 @@
2903 2878
2904 2879 if ($general_tab_submitted) {
2905 2880 foreach ($bool_fields as $field) {
2906 2881 if (isset($_POST['metasync_options']['general'][$field])) {
2907 - $metasync_options['general'][$field] = filter_var($_POST['metasync_options']['general'][$field], FILTER_VALIDATE_BOOLEAN);
2882 + $metasync_options['general'][$field] = filter_var(wp_unslash($_POST['metasync_options']['general'][$field]), FILTER_VALIDATE_BOOLEAN);
2908 2883 }else {
2909 2884 $metasync_options['general'][$field] = false;
2910 2885 }
2911 2886 }
2887 +
2888 + // SEO priority is a checkbox that stores a string rather than a
2889 + // bool, so it cannot ride the loop above. Only the exact opt-in
2890 + // value is stored; an unchecked box (or any tampered payload)
2891 + // falls back to custom-first, which is the pre-existing behaviour.
2892 + $metasync_options['general']['seo_priority'] =
2893 + (isset($_POST['metasync_options']['general']['seo_priority'])
2894 + && $_POST['metasync_options']['general']['seo_priority'] === Metasync_Seo_Precedence::PRIORITY_OTTO)
2895 + ? Metasync_Seo_Precedence::PRIORITY_OTTO
2896 + : 'custom';
2912 2897 }
2913 2898
2914 2899 if ($general_tab_submitted || $whitelabel_tab_submitted) {
2915 2900 foreach ($url_fields as $field) {
@@ -2914,9 +2899,10 @@
2914 2899 if ($general_tab_submitted || $whitelabel_tab_submitted) {
2915 2900 foreach ($url_fields as $field) {
2916 2901 if (isset($_POST['metasync_options']['general'][$field])) {
2917 2902
2918 - $value = trim($_POST['metasync_options']['general'][$field]);
2903 + // 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.
2904 + $value = trim(wp_unslash($_POST['metasync_options']['general'][$field]));
2919 2905
2920 2906 $whitelabel_clearable_url_fields = [
2921 2907 'white_label_plugin_author_uri',
2922 2908 'white_label_plugin_uri'
@@ -2932,9 +2918,9 @@
2932 2918 }
2933 2919
2934 2920 if (filter_var($value, FILTER_VALIDATE_URL)) {
2935 2921
2936 - $parsed_url = parse_url($value);
2922 + $parsed_url = wp_parse_url($value);
2937 2923 $host = isset($parsed_url['host']) ? $parsed_url['host'] : '';
2938 2924
2939 2925 if (strpos($host, '.') !== false || filter_var($host, FILTER_VALIDATE_IP)) {
2940 2926 $metasync_options['general'][$field] = esc_url_raw($value);
@@ -2951,31 +2937,54 @@
2951 2937 }
2952 2938
2953 2939 if ($general_tab_submitted) {
2954 2940 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']);
2941 + $metasync_options['general']['content_genius_sync_roles'] = array_map('sanitize_text_field', wp_unslash($_POST['metasync_options']['general']['content_genius_sync_roles']));
2956 2942 } else {
2957 2943 $metasync_options['general']['content_genius_sync_roles'] = array();
2958 2944 }
2959 2945 }
2960 2946
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();
2947 + // Breadcrumb settings live on their own admin page, which saves through
2948 + // options.php -> sanitize() rather than this AJAX handler. Only touch
2949 + // them here if the submitted form actually carried breadcrumb fields,
2950 + // otherwise a General-tab save would reset every breadcrumb option.
2951 + if ($general_tab_submitted
2952 + && isset($_POST['metasync_options']['breadcrumbs'])
2953 + && is_array($_POST['metasync_options']['breadcrumbs'])
2954 + ) {
2955 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each key is sanitised below (bool cast for flags, sanitize_text_field for the separator).
2956 + $breadcrumbs_input = wp_unslash($_POST['metasync_options']['breadcrumbs']);
2965 2957
2966 2958 if (!isset($metasync_options['breadcrumbs']) || !is_array($metasync_options['breadcrumbs'])) {
2967 2959 $metasync_options['breadcrumbs'] = array();
2968 2960 }
2969 2961
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}';
2962 + foreach (array('enabled', 'disable_schema', 'show_current_page') as $breadcrumb_flag) {
2963 + if (isset($breadcrumbs_input[$breadcrumb_flag])) {
2964 + $metasync_options['breadcrumbs'][$breadcrumb_flag] = !empty($breadcrumbs_input[$breadcrumb_flag]);
2965 + }
2966 + }
2967 + // An empty separator (Custom radio selected blank) keeps the
2968 + // stored value instead of rendering a blank gap between links.
2969 + if (isset($breadcrumbs_input['separator'])) {
2970 + $breadcrumb_separator = sanitize_text_field($breadcrumbs_input['separator']);
2971 + if ('' !== $breadcrumb_separator) {
2972 + $metasync_options['breadcrumbs']['separator'] = $breadcrumb_separator;
2973 + }
2974 + }
2975 + if (isset($breadcrumbs_input['home_label'])) {
2976 + $metasync_options['breadcrumbs']['home_label'] = sanitize_text_field($breadcrumbs_input['home_label']);
2977 + }
2978 + if (isset($breadcrumbs_input['home_url'])) {
2979 + $metasync_options['breadcrumbs']['home_url'] = esc_url_raw($breadcrumbs_input['home_url']);
2980 + }
2981 + if (isset($breadcrumbs_input['prefix_text'])) {
2982 + $metasync_options['breadcrumbs']['prefix_text'] = sanitize_text_field($breadcrumbs_input['prefix_text']);
2983 + }
2984 + if (isset($breadcrumbs_input['archive_label_format'])) {
2985 + $metasync_options['breadcrumbs']['archive_label_format'] = sanitize_text_field($breadcrumbs_input['archive_label_format']);
2986 + }
2978 2987 }
2979 2988
2980 2989 // Open Graph / article toggle checkboxes live under common_meta_settings.
2981 2990 // Unchecked HTML checkboxes are absent from $_POST, so we explicitly
@@ -2996,9 +3005,9 @@
2996 3005 $metasync_options['common_meta_settings'] = [];
2997 3006 }
2998 3007 foreach ($og_toggle_fields as $field) {
2999 3008 $posted = isset($_POST['metasync_options']['common_meta_settings'][$field])
3000 - ? sanitize_text_field($_POST['metasync_options']['common_meta_settings'][$field])
3009 + ? sanitize_text_field(wp_unslash($_POST['metasync_options']['common_meta_settings'][$field]))
3001 3010 : '';
3002 3011 $metasync_options['common_meta_settings'][$field] = ($posted === 'true') ? 'true' : 'false';
3003 3012 }
3004 3013 }
@@ -3005,14 +3014,15 @@
3005 3014
3006 3015 if ($general_tab_submitted && isset($_POST['metasync_options']['general']['default_page_builder'])) {
3007 3016 require_once plugin_dir_path(dirname(__FILE__)) . 'custom-pages/class-metasync-html-to-builder-converter.php';
3008 3017 $available_builders = Metasync_HTML_To_Builder_Converter::get_available_builders();
3009 - $builder_value = sanitize_text_field($_POST['metasync_options']['general']['default_page_builder']);
3018 + $builder_value = sanitize_text_field(wp_unslash($_POST['metasync_options']['general']['default_page_builder']));
3010 3019 if (isset($available_builders[$builder_value]) && $available_builders[$builder_value]['detected']) {
3011 3020 $metasync_options['general']['default_page_builder'] = $builder_value;
3012 3021 } else {
3013 3022 $metasync_options['general']['default_page_builder'] = 'gutenberg';
3014 3023 if (isset($available_builders[$builder_value]) && !$available_builders[$builder_value]['detected']) {
3024 + /* translators: %s: page builder name. */
3015 3025 $validation_errors[] = sprintf(__('Builder "%s" is not detected on this site. Reverted to Gutenberg.', 'metasync'), $available_builders[$builder_value]['label']);
3016 3026 }
3017 3027 }
3018 3028 }
@@ -3017,10 +3027,13 @@
3017 3027 }
3018 3028 }
3019 3029
3020 3030 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'] : [];
3031 + // Values are only previewed from the just-sanitised $metasync_options below; nothing here is stored.
3032 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- preview-only read
3033 + $gp = isset($_POST['metasync_options']['general']) && is_array($_POST['metasync_options']['general']) ? wp_unslash($_POST['metasync_options']['general']) : [];
3034 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- preview-only read
3035 + $wp = isset($_POST['metasync_options']['whitelabel']) && is_array($_POST['metasync_options']['whitelabel']) ? wp_unslash($_POST['metasync_options']['whitelabel']) : [];
3023 3036 $plugin_name = isset($gp['white_label_plugin_name']) ? trim((string) $gp['white_label_plugin_name']) : '';
3024 3037 $logo_light = isset($wp['logo_light']) ? trim((string) $wp['logo_light']) : '';
3025 3038 $logo_dark = isset($wp['logo_dark']) ? trim((string) $wp['logo_dark']) : '';
3026 3039 $author = isset($gp['white_label_plugin_author']) ? trim((string) $gp['white_label_plugin_author']) : '';
@@ -3184,9 +3197,10 @@
3184 3197 }
3185 3198
3186 3199 if (isset($_POST['metasync_options']['whitelabel'])) {
3187 3200
3188 - $whitelabel_data = $_POST['metasync_options']['whitelabel'];
3201 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every stored key below is validated/sanitised (FILTER_VALIDATE_URL + esc_url_raw for logos, sanitize_* for text).
3202 + $whitelabel_data = wp_unslash($_POST['metasync_options']['whitelabel']);
3189 3203 $existing_whitelabel = $metasync_options['whitelabel'] ?? [];
3190 3204
3191 3205 if (isset($whitelabel_data['logo'])) {
3192 3206 $logo_value = trim($whitelabel_data['logo']);
@@ -3227,9 +3241,9 @@
3227 3241 }
3228 3242 }
3229 3243
3230 3244 if (isset($whitelabel_data['settings_password'])) {
3231 - $password_value = (string) wp_unslash($whitelabel_data['settings_password']);
3245 + $password_value = (string) $whitelabel_data['settings_password'];
3232 3246
3233 3247 $hide_settings_enabled = isset($whitelabel_data['hide_settings']) && $whitelabel_data['hide_settings'] == '1';
3234 3248
3235 3249 if ($hide_settings_enabled && empty($password_value)) {
@@ -3327,9 +3341,9 @@
3327 3341
3328 3342 }
3329 3343
3330 3344 $redirect_url = isset($_GET['tab']) ?
3331 - admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-settings&tab='.$_GET['tab']) :
3345 + admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-settings&tab=' . sanitize_text_field(wp_unslash($_GET['tab']))) :
3332 3346 admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-settings');
3333 3347
3334 3348 // Also save edge cache settings if present in the payload (section renders inside this form)
3335 3349 if (class_exists('Metasync_Edge_Cache_Settings')) {
@@ -3336,8 +3350,9 @@
3336 3350 Metasync_Edge_Cache_Settings::save_from_post();
3337 3351 }
3338 3352
3339 3353 if ($general_tab_submitted && isset($_POST['metasync_llms_txt_settings'])) {
3354 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitised by sanitize_llms_txt_settings() before storage.
3340 3355 $llms_input = wp_unslash($_POST['metasync_llms_txt_settings']);
3341 3356 if (is_array($llms_input)) {
3342 3357 update_option('metasync_llms_txt_settings', $this->sanitize_llms_txt_settings($llms_input));
3343 3358 }
@@ -3389,9 +3404,10 @@
3389 3404 * Moved from Metasync_Admin – the admin class now delegates here.
3390 3405 */
3391 3406 public function ajax_save_execution_settings()
3392 3407 {
3393 - if (!isset($_POST['execution_settings_nonce']) || !wp_verify_nonce($_POST['execution_settings_nonce'], 'metasync_execution_settings_nonce')) {
3408 + $execution_nonce = isset($_POST['execution_settings_nonce']) ? sanitize_text_field(wp_unslash($_POST['execution_settings_nonce'])) : '';
3409 + if (!wp_verify_nonce($execution_nonce, 'metasync_execution_settings_nonce')) {
3394 3410 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
3395 3411 return;
3396 3412 }
3397 3413
@@ -3485,9 +3501,552 @@
3485 3501 wp_send_json_error(array('message' => 'Failed to save settings. Please try again.'));
3486 3502 }
3487 3503 }
3488 3504
3505 + # ------------------------------------------------------------------
3506 + # Headless Mode settings on the Advanced tab
3507 + # ------------------------------------------------------------------
3508 + # Metasync_Headless_Config is the only sanitiser for these four values.
3509 + # Nothing here validates a domain, a policy or a prefix — it collects
3510 + # what was submitted, hands it to update_settings(), and then shows back
3511 + # whatever that stored, so a value the sanitiser refused reads as refused
3512 + # instead of being echoed back as though it saved.
3513 + # ------------------------------------------------------------------
3514 +
3489 3515 /**
3516 + * Field prefix shared by every control in the Headless Mode block.
3517 + *
3518 + * The controls sit inside the Advanced tab's outer form, which posts to
3519 + * options.php. Naming them outside `metasync_options[...]` keeps them out
3520 + * of sanitize() entirely, so an unrelated save of that form can neither
3521 + * store them unsanitised nor clear them.
3522 + */
3523 + const HEADLESS_FIELD_PREFIX = 'metasync_headless_';
3524 +
3525 + /**
3526 + * Nonce action and request field for the Headless Mode save.
3527 + */
3528 + const HEADLESS_NONCE_ACTION = 'metasync_headless_settings_nonce';
3529 + const HEADLESS_NONCE_FIELD = 'headless_settings_nonce';
3530 +
3531 + /**
3532 + * Render the Headless Mode section of the Advanced settings accordion.
3533 + *
3534 + * Values come from Metasync_Headless_Config::get_settings(), which is the
3535 + * sanitised stored state rather than anything the operator typed.
3536 + *
3537 + * @return void
3538 + */
3539 + public function render_headless_mode_section()
3540 + {
3541 + $settings = Metasync_Headless_Config::get_settings();
3542 + $enabled = !empty($settings['enabled']);
3543 + $active = Metasync_Headless_Config::is_active();
3544 + $prefix = self::HEADLESS_FIELD_PREFIX;
3545 +
3546 + $policy_labels = array(
3547 + Metasync_Headless_Config::SLASH_PRESERVE => 'Preserve — hand back whatever WordPress produced (default)',
3548 + Metasync_Headless_Config::SLASH_STRIP => 'Strip — no trailing slash',
3549 + Metasync_Headless_Config::SLASH_ADD => 'Add — always end with a trailing slash',
3550 + );
3551 + ?>
3552 + <div style="background: var(--dashboard-card-bg); padding: 20px; border-radius: 8px;">
3553 + <p style="color: var(--dashboard-text-secondary); margin: 0 0 20px 0;">
3554 + Headless Mode describes a separate frontend that serves the public site, so this plugin can build
3555 + URLs on that host instead of this one. With the mode off nothing here changes how the site behaves.
3556 + </p>
3557 +
3558 + <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;">
3559 + <p style="color: var(--dashboard-text-secondary); margin: 0;">
3560 + Headless Mode is for sites where WordPress manages content and a separate frontend serves the public pages.
3561 + Set the public frontend URL below, then choose how MetaSync should build those public URLs.
3562 + </p>
3563 + </div>
3564 +
3565 + <div id="metasync-headless-settings-message" style="display: none; margin-bottom: 20px;"></div>
3566 +
3567 + <form id="metasync-headless-settings-form" method="post" data-headless-active="<?php echo $active ? '1' : '0'; ?>">
3568 + <?php wp_nonce_field(self::HEADLESS_NONCE_ACTION, self::HEADLESS_NONCE_FIELD); ?>
3569 +
3570 + <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;">
3571 + <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;">
3572 + <?php
3573 + # Hidden field first, checkbox second, same name. An unchecked
3574 + # checkbox posts nothing at all, and normalize() reads an absent
3575 + # `enabled` key as false — so without this pair, a submission that
3576 + # simply did not include the checkbox would switch the mode off as
3577 + # a side effect. With it, "off" arrives as an explicit 0.
3578 + ?>
3579 + <input type="hidden" name="<?php echo esc_attr($prefix . 'enabled'); ?>" value="0" />
3580 + <input type="checkbox"
3581 + id="<?php echo esc_attr($prefix . 'enabled'); ?>"
3582 + name="<?php echo esc_attr($prefix . 'enabled'); ?>"
3583 + value="1"
3584 + style="width: 18px; height: 18px; cursor: pointer;"
3585 + <?php checked($enabled, true); ?> />
3586 + <span>Enable Headless Mode</span>
3587 + </label>
3588 +
3589 + <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;">
3590 + <p style="color: var(--dashboard-text-secondary); margin: 0; font-size: 13px;">
3591 + <strong style="color: var(--dashboard-warning, #f59e0b);">Changing this can affect your public frontend.</strong><br />
3592 + When Headless Mode is active, the GraphQL SEO field and generated URLs follow the settings below.
3593 + Coordinate with whoever maintains the frontend before disabling it or changing its public URL.
3594 + </p>
3595 + </div>
3596 +
3597 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 12px 0 0 0;">
3598 + Current state:
3599 + <strong style="color: var(--dashboard-text-primary);"><?php echo $enabled ? 'enabled' : 'disabled'; ?></strong><?php
3600 + if ($enabled && !$active) {
3601 + ?> &mdash; but not yet in effect: a frontend domain is required before any headless URL can be built.<?php
3602 + }
3603 + ?>
3604 + </p>
3605 + </div>
3606 +
3607 + <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;">
3608 + <div style="margin-bottom: 24px;">
3609 + <label for="<?php echo esc_attr($prefix . 'frontend_domain'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px; font-weight: 500;">
3610 + Public frontend URL:
3611 + </label>
3612 + <input type="url"
3613 + id="<?php echo esc_attr($prefix . 'frontend_domain'); ?>"
3614 + name="<?php echo esc_attr($prefix . 'frontend_domain'); ?>"
3615 + value="<?php echo esc_attr($settings['frontend_domain']); ?>"
3616 + placeholder="https://www.example.com"
3617 + 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);" />
3618 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3619 + Scheme and host only &mdash; for example <code>https://www.example.com</code>. A path is
3620 + refused: <code>https://example.com/blog</code> will not save, because only per-post-type
3621 + prefixes exist and a shared path segment could never be put back on terms, archives or
3622 + the homepage. Use a path prefix below instead. A bare host is treated as https.
3623 + </p>
3624 + </div>
3625 +
3626 + <div style="margin-bottom: 24px;">
3627 + <label for="<?php echo esc_attr($prefix . 'slash_policy'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px; font-weight: 500;">
3628 + Trailing Slash Policy:
3629 + </label>
3630 + <select id="<?php echo esc_attr($prefix . 'slash_policy'); ?>"
3631 + name="<?php echo esc_attr($prefix . 'slash_policy'); ?>"
3632 + 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);">
3633 + <?php foreach (Metasync_Headless_Config::slash_policies() as $policy) { ?>
3634 + <option value="<?php echo esc_attr($policy); ?>" <?php selected($settings['slash_policy'], $policy); ?>>
3635 + <?php echo esc_html(isset($policy_labels[$policy]) ? $policy_labels[$policy] : $policy); ?>
3636 + </option>
3637 + <?php } ?>
3638 + </select>
3639 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3640 + Choose how generated public URLs end. For example, <code>/about</code> stays unchanged with Preserve,
3641 + becomes <code>/about</code> with Strip, or <code>/about/</code> with Add. Match your frontend router.
3642 + </p>
3643 + </div>
3644 +
3645 + <div>
3646 + <label for="<?php echo esc_attr($prefix . 'path_prefixes'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px; font-weight: 500;">
3647 + Path Prefixes:
3648 + </label>
3649 + <textarea id="<?php echo esc_attr($prefix . 'path_prefixes'); ?>"
3650 + name="<?php echo esc_attr($prefix . 'path_prefixes'); ?>"
3651 + rows="4"
3652 + placeholder="post = blog"
3653 + 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>
3654 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3655 + Map WordPress content types to frontend URL folders:
3656 + </p>
3657 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 8px 0 0 0;">
3658 + One per line, for example <code>post = blog</code> produces <code>/blog/example-post</code>.
3659 + Leave empty when your frontend uses the same paths as WordPress.
3660 + </p>
3661 + </div>
3662 +
3663 + <details style="margin-top: 24px;">
3664 + <summary style="color: var(--dashboard-text-primary); font-weight: 500; cursor: pointer;">Advanced refresh settings</summary>
3665 + <div style="padding-top: 16px;">
3666 + <label for="<?php echo esc_attr($prefix . 'refresh_interval_minutes'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px;">
3667 + Background refresh interval (minutes):
3668 + </label>
3669 + <input type="number"
3670 + id="<?php echo esc_attr($prefix . 'refresh_interval_minutes'); ?>"
3671 + name="<?php echo esc_attr($prefix . 'refresh_interval_minutes'); ?>"
3672 + value="<?php echo esc_attr($settings['refresh_interval_minutes']); ?>"
3673 + min="<?php echo esc_attr((string) Metasync_Headless_Config::REFRESH_INTERVAL_MIN_MINUTES); ?>"
3674 + max="<?php echo esc_attr((string) Metasync_Headless_Config::REFRESH_INTERVAL_MAX_MINUTES); ?>"
3675 + step="1"
3676 + 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);" />
3677 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3678 + How often the background job re-checks a URL's stored OTTO data against OTTO directly, for
3679 + any post or term OTTO's deployment webhook missed. Does not affect the webhook, which stays
3680 + the primary, immediate update path. Between
3681 + <?php echo esc_html((string) Metasync_Headless_Config::REFRESH_INTERVAL_MIN_MINUTES); ?> and
3682 + <?php echo esc_html((string) Metasync_Headless_Config::REFRESH_INTERVAL_MAX_MINUTES); ?> minutes;
3683 + defaults to <?php echo esc_html((string) Metasync_Headless_Config::REFRESH_INTERVAL_DEFAULT_MINUTES); ?>.
3684 + </p>
3685 + </div>
3686 + </details>
3687 + </div>
3688 +
3689 + <button type="button"
3690 + id="metasync-headless-settings-save-btn"
3691 + class="metasync-btn-primary"
3692 + 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);">
3693 + <span class="save-text">Save Headless Settings</span>
3694 + </button>
3695 + </form>
3696 + </div>
3697 + <script>
3698 + jQuery(document).ready(function ($) {
3699 + // Click, not submit: this form is nested inside #metaSyncGeneralSetting,
3700 + // so the browser drops the inner form tag and a submit handler would
3701 + // never fire on its own.
3702 + var $saveBtn = $('#metasync-headless-settings-save-btn');
3703 + var $message = $('#metasync-headless-settings-message');
3704 + var headlessModeWasActive = <?php echo $active ? 'true' : 'false'; ?>;
3705 + 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?';
3706 + 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?';
3707 + var headlessModeSavedSettings = <?php echo wp_json_encode(array(
3708 + 'enabled' => !empty($settings['enabled']),
3709 + 'frontend_domain' => $settings['frontend_domain'],
3710 + 'slash_policy' => $settings['slash_policy'],
3711 + 'path_prefixes' => self::format_headless_path_prefixes($settings['path_prefixes']),
3712 + 'refresh_interval_minutes' => $settings['refresh_interval_minutes'],
3713 + )); ?>;
3714 +
3715 + // This mirrors normalize_domain()'s shape rules so a value the
3716 + // server will refuse cannot look active here. Keep this deliberately
3717 + // small: it is a confirmation decision, not a client-side validator,
3718 + // and the server remains authoritative. Mirrors can drift.
3719 + function isUsableFrontendDomain(value) {
3720 + // PHP's trim() strips only space, tab, newline, carriage return,
3721 + // NUL and vertical tab, while $.trim() and String.trim() strip the
3722 + // whole Unicode set. Trimming a non-breaking space or BOM that the
3723 + // server keeps would make a pasted domain look usable while
3724 + // normalize_domain() rejects it — the guard stays silent and the
3725 + // mode deactivates anyway. Match the server charlist exactly.
3726 + var candidate = (value || '').replace(/^[ \t\n\r\0\x0B]+|[ \t\n\r\0\x0B]+$/g, '');
3727 + var parsed;
3728 + var authority;
3729 + var host;
3730 + var path;
3731 +
3732 + if (candidate === '') {
3733 + return false;
3734 + }
3735 +
3736 + if (candidate.indexOf('//') === -1) {
3737 + candidate = 'https://' + candidate.replace(/^\/+/, '');
3738 + }
3739 +
3740 + parsed = candidate.match(/^(?:([a-z][a-z0-9+.-]*):)?\/\/([^/?#]*)([^?#]*)/i);
3741 + if (!parsed || (parsed[1] && parsed[1].toLowerCase() !== 'http' && parsed[1].toLowerCase() !== 'https')) {
3742 + return false;
3743 + }
3744 +
3745 + path = parsed[3] || '';
3746 + if (path.replace(/^\/+|\/+$/g, '') !== '') {
3747 + return false;
3748 + }
3749 +
3750 + authority = parsed[2].replace(/^.*@/, '');
3751 + if (authority.charAt(0) === '[') {
3752 + return /^\[[0-9a-f:.]+\](?::\d+)?$/i.test(authority);
3753 + }
3754 +
3755 + host = authority.replace(/:\d+$/, '');
3756 + if (!/^[\p{L}\p{N}._-]+$/u.test(host) || host.indexOf('..') !== -1) {
3757 + return false;
3758 + }
3759 +
3760 + return !/^[.-]|[.-]$/.test(host) && /[\p{L}\p{N}]/u.test(host);
3761 + }
3762 +
3763 + function showMessage(lines, type) {
3764 + var $list = $('<div/>');
3765 + $.each(lines, function (i, line) {
3766 + $list.append($('<p/>').css('margin', i === 0 ? '0' : '8px 0 0 0').text(line));
3767 + });
3768 +
3769 + $message
3770 + .css({
3771 + 'background': type === 'success' ? 'rgba(34, 197, 94, 0.1)' : 'rgba(239, 68, 68, 0.1)',
3772 + 'border': '1px solid ' + (type === 'success' ? 'rgba(34, 197, 94, 0.3)' : 'rgba(239, 68, 68, 0.3)'),
3773 + 'color': type === 'success' ? '#22c55e' : '#ef4444',
3774 + 'padding': '12px 16px',
3775 + 'border-radius': '6px',
3776 + 'font-size': '14px',
3777 + 'line-height': '1.5'
3778 + })
3779 + .empty()
3780 + .append($list)
3781 + .show();
3782 + }
3783 +
3784 + // Repaint from what was actually stored, never from what was typed, so a
3785 + // refused value reads as refused rather than looking saved.
3786 + function applyStoredSettings(settings) {
3787 + if (!settings) {
3788 + return;
3789 + }
3790 + headlessModeSavedSettings = $.extend({}, settings);
3791 + headlessModeWasActive = !!settings.enabled && isUsableFrontendDomain(settings.frontend_domain);
3792 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>').prop('checked', !!settings.enabled);
3793 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>').val(settings.frontend_domain);
3794 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'slash_policy'); ?>').val(settings.slash_policy);
3795 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'path_prefixes'); ?>').val(settings.path_prefixes);
3796 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'refresh_interval_minutes'); ?>').val(settings.refresh_interval_minutes);
3797 + }
3798 +
3799 + $saveBtn.on('click', function (e) {
3800 + e.preventDefault();
3801 +
3802 + var data = {
3803 + action: 'metasync_save_headless_settings',
3804 + <?php echo esc_js(self::HEADLESS_NONCE_FIELD); ?>: $('#<?php echo esc_js(self::HEADLESS_NONCE_FIELD); ?>').val(),
3805 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>').is(':checked') ? '1' : '0',
3806 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>').val(),
3807 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'slash_policy'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'slash_policy'); ?>').val(),
3808 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'path_prefixes'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'path_prefixes'); ?>').val(),
3809 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'refresh_interval_minutes'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'refresh_interval_minutes'); ?>').val()
3810 + };
3811 +
3812 + var submittedModeIsEnabled = data['<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>'] === '1';
3813 + var submittedDomain = data['<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>'];
3814 + var submittedModeIsActive = submittedModeIsEnabled
3815 + && isUsableFrontendDomain(submittedDomain);
3816 +
3817 + if (submittedModeIsEnabled && !submittedModeIsActive) {
3818 + showMessage(['Enter a valid public frontend URL before enabling Headless Mode. Use a scheme and host only, for example https://www.example.com.'], 'error');
3819 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>').trigger('focus');
3820 + return;
3821 + }
3822 +
3823 + var confirmationMessage = submittedModeIsEnabled ? headlessDomainWarning : headlessDisableWarning;
3824 +
3825 + if (headlessModeWasActive && !submittedModeIsActive && !window.confirm(confirmationMessage)) {
3826 + applyStoredSettings(headlessModeSavedSettings);
3827 + return;
3828 + }
3829 +
3830 + $saveBtn.prop('disabled', true);
3831 + $saveBtn.find('.save-text').text('Saving...');
3832 + $message.hide();
3833 +
3834 + $.ajax({
3835 + url: window.ajaxurl,
3836 + type: 'POST',
3837 + data: data,
3838 + success: function (response) {
3839 + if (response && response.success) {
3840 + applyStoredSettings(response.data.settings);
3841 + var lines = [response.data.message];
3842 + if (response.data.notes && response.data.notes.length) {
3843 + lines = lines.concat(response.data.notes);
3844 + }
3845 + showMessage(lines, response.data.notes && response.data.notes.length ? 'error' : 'success');
3846 + } else {
3847 + showMessage([(response && response.data && response.data.message) || 'Error saving Headless Mode settings.'], 'error');
3848 + }
3849 + $saveBtn.prop('disabled', false);
3850 + $saveBtn.find('.save-text').text('Save Headless Settings');
3851 + },
3852 + error: function () {
3853 + showMessage(['An error occurred while saving Headless Mode settings. Please try again.'], 'error');
3854 + $saveBtn.prop('disabled', false);
3855 + $saveBtn.find('.save-text').text('Save Headless Settings');
3856 + }
3857 + });
3858 + });
3859 + });
3860 + </script>
3861 + <?php
3862 + }
3863 +
3864 + /**
3865 + * Render the stored post-type => prefix map back into the textarea format.
3866 + *
3867 + * @param mixed $prefixes Stored prefix map.
3868 + * @return string One `post_type = prefix` entry per line.
3869 + */
3870 + public static function format_headless_path_prefixes($prefixes)
3871 + {
3872 + if (!is_array($prefixes)) {
3873 + return '';
3874 + }
3875 +
3876 + $lines = array();
3877 +
3878 + foreach ($prefixes as $post_type => $prefix) {
3879 + $lines[] = $post_type . ' = ' . $prefix;
3880 + }
3881 +
3882 + return implode("\n", $lines);
3883 + }
3884 +
3885 + /**
3886 + * Read the textarea format back into a post-type => prefix map.
3887 + *
3888 + * One entry per line, `post_type = prefix`. A plain text format is enough
3889 + * for a demo and needs no repeater UI. A line with no `=` names no post
3890 + * type, so it is skipped rather than guessed at; everything that does
3891 + * survive is still handed to Metasync_Headless_Config::normalize(), which
3892 + * is what actually decides whether a post type and prefix are usable.
3893 + *
3894 + * @param mixed $text Raw textarea contents.
3895 + * @return array<string,string> Unsanitised map, keyed by post type.
3896 + */
3897 + public static function parse_headless_path_prefixes($text)
3898 + {
3899 + $map = array();
3900 +
3901 + if (!is_string($text)) {
3902 + return $map;
3903 + }
3904 +
3905 + foreach (preg_split('/\R/', $text) as $line) {
3906 + if (strpos($line, '=') === false) {
3907 + continue;
3908 + }
3909 +
3910 + list($post_type, $prefix) = explode('=', $line, 2);
3911 +
3912 + $post_type = trim($post_type);
3913 + if ($post_type === '') {
3914 + continue;
3915 + }
3916 +
3917 + $map[$post_type] = trim($prefix);
3918 + }
3919 +
3920 + return $map;
3921 + }
3922 +
3923 + /**
3924 + * Merge a Headless Mode submission over the stored settings and persist it.
3925 + *
3926 + * Additive on purpose: a key the request did not carry keeps whatever is
3927 + * already stored. normalize() treats an absent `enabled` as false, so a
3928 + * submission that omitted the checkbox would otherwise switch the mode off
3929 + * as a side effect of saving something else. The hidden field in front of
3930 + * the checkbox is the other half of that guard — it makes "off" an explicit
3931 + * `0` rather than an absence, so unticking the box still works.
3932 + *
3933 + * Sanitising is left entirely to Metasync_Headless_Config.
3934 + *
3935 + * @param mixed $submitted Raw request data, normally $_POST.
3936 + * @return array The settings that were actually stored.
3937 + */
3938 + public function save_headless_settings($submitted)
3939 + {
3940 + if (!is_array($submitted)) {
3941 + $submitted = array();
3942 + }
3943 +
3944 + $prefix = self::HEADLESS_FIELD_PREFIX;
3945 + $input = Metasync_Headless_Config::get_settings();
3946 +
3947 + if (isset($submitted[$prefix . 'enabled'])) {
3948 + $input['enabled'] = $submitted[$prefix . 'enabled'];
3949 + }
3950 +
3951 + if (isset($submitted[$prefix . 'frontend_domain'])) {
3952 + $input['frontend_domain'] = wp_unslash($submitted[$prefix . 'frontend_domain']);
3953 + }
3954 +
3955 + if (isset($submitted[$prefix . 'slash_policy'])) {
3956 + $input['slash_policy'] = $submitted[$prefix . 'slash_policy'];
3957 + }
3958 +
3959 + if (isset($submitted[$prefix . 'path_prefixes'])) {
3960 + $input['path_prefixes'] = self::parse_headless_path_prefixes(
3961 + wp_unslash($submitted[$prefix . 'path_prefixes'])
3962 + );
3963 + }
3964 +
3965 + if (isset($submitted[$prefix . 'refresh_interval_minutes'])) {
3966 + $input['refresh_interval_minutes'] = $submitted[$prefix . 'refresh_interval_minutes'];
3967 + }
3968 +
3969 + return Metasync_Headless_Config::update_settings($input);
3970 + }
3971 +
3972 + /**
3973 + * AJAX handler for the Headless Mode block on the Advanced tab.
3974 + *
3975 + * Same nonce-then-capability shape as the other Advanced tab handlers.
3976 + *
3977 + * @return void
3978 + */
3979 + /**
3980 + * Did the last write actually land?
3981 + *
3982 + * update_settings() hands back what it meant to store, not proof that it
3983 + * reached the database: a full disk, a failing object cache or a read-only
3984 + * replica all produce a silent no-op. Telling an operator their headless
3985 + * settings saved when they did not is worse than telling them nothing —
3986 + * they would go on to wonder why the frontend sees none of it.
3987 + *
3988 + * @param array $intended What the save call reported storing.
3989 + * @return bool
3990 + */
3991 + private function headless_settings_persisted(array $intended)
3992 + {
3993 + Metasync_Headless_Config::flush();
3994 +
3995 + return Metasync_Headless_Config::get_settings() == $intended;
3996 + }
3997 +
3998 + public function ajax_save_headless_settings()
3999 + {
4000 + $prefix = self::HEADLESS_FIELD_PREFIX;
4001 +
4002 + $headless_nonce = isset($_POST[self::HEADLESS_NONCE_FIELD]) ? sanitize_text_field(wp_unslash($_POST[self::HEADLESS_NONCE_FIELD])) : '';
4003 + if (!wp_verify_nonce($headless_nonce, self::HEADLESS_NONCE_ACTION)) {
4004 + wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
4005 + } elseif (!Metasync::current_user_has_plugin_access()) {
4006 + wp_send_json_error(array('message' => 'Insufficient permissions to save settings.'));
4007 + } else {
4008 + // phpcs:disable WordPress.Security.ValidatedSanitizedInput -- sanitised via Metasync_Headless_Config::normalize_domain() inside save_headless_settings().
4009 + $submitted_domain = isset($_POST[$prefix . 'frontend_domain'])
4010 + ? trim((string) wp_unslash($_POST[$prefix . 'frontend_domain']))
4011 + : '';
4012 + // phpcs:enable WordPress.Security.ValidatedSanitizedInput
4013 +
4014 + $stored = $this->save_headless_settings($_POST);
4015 +
4016 + if (!$this->headless_settings_persisted($stored)) {
4017 + wp_send_json_error(array(
4018 + 'message' => 'Those settings could not be saved. Nothing has changed — please try again, and check the site error log if it keeps happening.',
4019 + ));
4020 + }
4021 +
4022 + # Say so when the sanitiser refused something, rather than leaving the
4023 + # operator to spot that a field came back empty.
4024 + $notes = array();
4025 +
4026 + if ($submitted_domain !== '' && $stored['frontend_domain'] === '') {
4027 + $notes[] = 'That frontend domain was not saved. Give a scheme and host only, with no path — for example https://www.example.com.';
4028 + }
4029 +
4030 + if (!empty($stored['enabled']) && $stored['frontend_domain'] === '') {
4031 + $notes[] = 'Headless Mode is switched on but has no usable frontend domain, so nothing behaves as headless yet.';
4032 + }
4033 +
4034 + wp_send_json_success(array(
4035 + 'message' => 'Headless Mode settings saved.',
4036 + 'notes' => $notes,
4037 + 'settings' => array(
4038 + 'enabled' => !empty($stored['enabled']),
4039 + 'frontend_domain' => $stored['frontend_domain'],
4040 + 'slash_policy' => $stored['slash_policy'],
4041 + 'path_prefixes' => self::format_headless_path_prefixes($stored['path_prefixes']),
4042 + 'refresh_interval_minutes' => $stored['refresh_interval_minutes'],
4043 + ),
4044 + ));
4045 + }
4046 + }
4047 +
4048 + /**
3490 4049 * AJAX handler for saving SEO / Indexation Control settings.
3491 4050 *
3492 4051 * Moved from Metasync_Admin – the admin class now delegates here.
3493 4052 */
@@ -3493,13 +4052,26 @@
3493 4052 */
3494 4053 public function meta_sync_save_seo_controls()
3495 4054 {
3496 4055 try {
3497 - if (!isset($_POST['meta_sync_seo_controls_nonce']) || !wp_verify_nonce($_POST['meta_sync_seo_controls_nonce'], 'meta_sync_seo_controls_nonce')) {
4056 + $seo_controls_nonce = isset($_POST['meta_sync_seo_controls_nonce']) ? sanitize_text_field(wp_unslash($_POST['meta_sync_seo_controls_nonce'])) : '';
4057 + if (!wp_verify_nonce($seo_controls_nonce, 'meta_sync_seo_controls_nonce')) {
3498 4058 wp_send_json_error(array('message' => 'Invalid nonce'));
3499 4059 return;
3500 4060 }
3501 4061
4062 + // Same nonce-then-capability shape as the other settings save
4063 + // handlers: the nonce is not authorization, so a logged-in user
4064 + // without plugin access must still be refused here.
4065 + if (!Metasync::current_user_has_plugin_access()) {
4066 + wp_send_json_error(array('message' => 'Insufficient permissions.'), 403);
4067 + // The return is unreachable in WordPress (wp_send_json_error
4068 + // terminates the request) but keeps the handler safe under the
4069 + // non-terminating stubs the unit tests run with.
4070 + // @phpstan-ignore-next-line deadCode.unreachable
4071 + return;
4072 + }
4073 +
3502 4074 $current_options = Metasync::get_option();
3503 4075
3504 4076 $original_options = json_decode(json_encode($current_options), true);
3505 4077
@@ -3565,10 +4137,9 @@
3565 4137 }
3566 4138 wp_send_json_error(array('message' => $error_message));
3567 4139 }
3568 4140 } catch (Exception $e) {
3569 - error_log('Indexation Control AJAX Exception: ' . $e->getMessage());
3570 - error_log('Indexation Control AJAX Stack trace: ' . $e->getTraceAsString());
4141 + error_log('Indexation Control AJAX Exception: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3571 4142 wp_send_json_error(array('message' => 'Error saving settings: ' . $e->getMessage()));
3572 4143 }
3573 4144 }
3574 4145
@@ -3580,12 +4151,12 @@
3580 4151 * @return bool True on success, false on failure
3581 4152 */
3582 4153 public function save_bing_inline_settings_ajax()
3583 4154 {
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;
4155 + $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
4156 + $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
4157 + $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
4158 + $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 4159
3589 4160 $existing_settings = get_option('metasync_options_bing_instant_indexing', []);
3590 4161
3591 4162 $new_settings = [
@@ -3609,9 +4180,9 @@
3609 4180 $safe_key = sanitize_file_name( $api_key );
3610 4181 $file_path = ABSPATH . $safe_key . '.txt';
3611 4182 $real_path = realpath( ABSPATH );
3612 4183 if ( false === $real_path || 0 !== strpos( realpath( dirname( $file_path ) ), $real_path ) ) {
3613 - error_log( 'Bing IndexNow: Refusing to write outside ABSPATH' );
4184 + 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 4185 return false;
3615 4186 }
3616 4187 // serve the key virtually via template_redirect instead of
3617 4188 // writing a physical {key}.txt. An on-disk file is served statically
@@ -3624,9 +4195,9 @@
3624 4195 }
3625 4196 Metasync_Bing_Instant_Index::register_virtual_key($safe_key);
3626 4197
3627 4198 if (file_exists($file_path)) {
3628 - @unlink($file_path);
4199 + @wp_delete_file($file_path);
3629 4200 }
3630 4201
3631 4202 $current_options = Metasync::get_option();
3632 4203 if (!isset($current_options['seo_controls']['enable_binginstantindex']) || $current_options['seo_controls']['enable_binginstantindex'] !== 'true') {