PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | includes/class-metasync-settings-registration.php +1640 -398 2.6.24 → 2.7.2 View file →
@@ -14,8 +14,23 @@
14 14 * @subpackage Metasync/includes
15 15 */
16 16 class Metasync_Settings_Registration
17 17 {
18 + private static $options_generation_at_sanitize = null;
19 + private static $allow_recovery_password_write = false;
20 +
21 + public static function authorize_recovery_password_write($allow = true)
22 + {
23 + self::$allow_recovery_password_write = (bool) $allow;
24 + }
25 + private static $settings_recovery_lock_owner = '';
26 + private static $abort_locked_settings_update = false;
27 + private static $defer_settings_recovery_lock_release = false;
28 +
29 + public static function capture_options_generation()
30 + {
31 + self::$options_generation_at_sanitize = (int) get_option('metasync_pw_reset_generation', 0);
32 + }
18 33 /** @var self|null */
19 34 private static $instance = null;
20 35
21 36 /**
@@ -30,10 +45,98 @@
30 45 }
31 46 return self::$instance;
32 47 }
33 48
34 - private function __construct() {}
49 + private function __construct() {
50 + add_filter('pre_update_option_metasync_options', array($this, 'protect_recovery_password_update'), 10, 3);
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'));
53 + }
35 54
55 + private function acquire_settings_recovery_lock()
56 + {
57 + if (self::$settings_recovery_lock_owner !== '') {
58 + self::$abort_locked_settings_update = false;
59 + Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
60 + return true;
61 + }
62 +
63 + $owner = '';
64 + if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($owner)) {
65 + self::$abort_locked_settings_update = true;
66 + return false;
67 + }
68 +
69 + self::$settings_recovery_lock_owner = $owner;
70 + self::$abort_locked_settings_update = false;
71 + register_shutdown_function(array($this, 'force_release_settings_recovery_lock'));
72 + Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
73 + return true;
74 + }
75 +
76 + public function release_settings_recovery_lock()
77 + {
78 + if (self::$defer_settings_recovery_lock_release) {
79 + return;
80 + }
81 + if (self::$settings_recovery_lock_owner === '') {
82 + return;
83 + }
84 +
85 + Metasync_Admin_Ajax::instance()->release_recovery_lock(self::$settings_recovery_lock_owner);
86 + self::$settings_recovery_lock_owner = '';
87 + self::$options_generation_at_sanitize = null;
88 + self::$allow_recovery_password_write = false;
89 + }
90 +
91 + public function force_release_settings_recovery_lock()
92 + {
93 + self::$defer_settings_recovery_lock_release = false;
94 + $this->release_settings_recovery_lock();
95 + }
96 +
97 + public function protect_recovery_password_update($new_value, $old_value, $option)
98 + {
99 + if (self::$allow_recovery_password_write) {
100 + return $new_value;
101 + }
102 + if (self::$options_generation_at_sanitize === null && is_array($new_value) && is_array($old_value)
103 + && isset($new_value['whitelabel']) && is_array($new_value['whitelabel'])
104 + && array_key_exists('settings_password', $new_value['whitelabel'])) {
105 + if (!$this->acquire_settings_recovery_lock()) {
106 + self::$abort_locked_settings_update = false;
107 + return $old_value;
108 + }
109 + Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
110 + $fresh = Metasync::get_option('whitelabel');
111 + if (is_array($fresh) && array_key_exists('settings_password', $fresh)) {
112 + $proposed = (string) $new_value['whitelabel']['settings_password'];
113 + $fresh_value = (string) $fresh['settings_password'];
114 + if (Metasync::is_encrypted_secret($proposed) && !Metasync::is_encrypted_secret($fresh_value)
115 + && Metasync::decrypt_secret($proposed) === $fresh_value) {
116 + return $new_value;
117 + }
118 + $new_value['whitelabel']['settings_password'] = $fresh['settings_password'];
119 + }
120 + }
121 + if (self::$abort_locked_settings_update) {
122 + self::$abort_locked_settings_update = false;
123 + return $old_value;
124 + }
125 + if (self::$options_generation_at_sanitize === null || !is_array($new_value) || !is_array($old_value)) return $new_value;
126 + $current_generation = (int) get_option('metasync_pw_reset_generation', 0);
127 + if ($current_generation !== (int) self::$options_generation_at_sanitize
128 + && isset($new_value['whitelabel'])
129 + && is_array($new_value['whitelabel'])
130 + && array_key_exists('settings_password', $new_value['whitelabel'])) {
131 + // Returning the same value update_option() already considers old
132 + // prevents any database write, even if this request's option cache
133 + // predates the completed recovery request.
134 + return $old_value;
135 + }
136 + return $new_value;
137 + }
138 +
36 139 /**
37 140 * Helper – replicate Metasync_Admin::get_effective_menu_title() without
38 141 * needing an admin instance.
39 142 */
@@ -77,15 +180,23 @@
77 180 */
78 181 private function handle_plugin_access_roles_save()
79 182 {
80 183 if (isset($_POST['save_plugin_access_roles']) && $_POST['save_plugin_access_roles'] === 'yes') {
81 - 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')) {
82 186
83 187 if (!Metasync::current_user_has_plugin_access()) {
84 - 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')));
85 189 exit;
86 190 }
87 191
192 + self::$defer_settings_recovery_lock_release = true;
193 + if (!$this->acquire_settings_recovery_lock()) {
194 + self::$defer_settings_recovery_lock_release = false;
195 + wp_die('Password recovery is being processed. Please try again later.');
196 + }
197 + self::$defer_settings_recovery_lock_release = false;
198 +
88 199 $metasync_options = Metasync::get_option();
89 200 if (!is_array($metasync_options)) {
90 201 $metasync_options = array();
91 202 }
@@ -93,9 +204,9 @@
93 204 $metasync_options['general'] = array();
94 205 }
95 206
96 207 if (isset($_POST['plugin_access_roles']) && is_array($_POST['plugin_access_roles'])) {
97 - $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']));
98 209 } else {
99 210 $metasync_options['general']['plugin_access_roles'] = array();
100 211 }
101 212
@@ -101,13 +212,13 @@
101 212
102 213 Metasync::set_option($metasync_options);
103 214
104 215 $redirect_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=advanced&access_roles_saved=1');
105 - wp_redirect($redirect_url);
216 + wp_safe_redirect($redirect_url);
106 217 exit;
107 218 } else {
108 219 $redirect_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=advanced&access_roles_error=1&message=' . urlencode('Invalid security token'));
109 - wp_redirect($redirect_url);
220 + wp_safe_redirect($redirect_url);
110 221 exit;
111 222 }
112 223 }
113 224 }
@@ -117,9 +228,8 @@
117 228 */
118 229 public function settings_page_init()
119 230 {
120 231 Metasync_Connect_Manager::instance()->handle_session_management_early();
121 - Metasync_Connect_Manager::instance()->handle_whitelabel_password_early();
122 232
123 233 Metasync_Debug_Manager::instance()->handle_debug_mode_operations();
124 234 Metasync_Debug_Manager::instance()->handle_error_log_operations();
125 235 Metasync_Debug_Manager::instance()->handle_clear_all_settings();
@@ -139,9 +249,8 @@
139 249 $SECTION_SEO_CONTROLS = Metasync_Admin::SECTION_SEO_CONTROLS;
140 250 $SECTION_SEO_CONTROLS_ADVANCED = Metasync_Admin::SECTION_SEO_CONTROLS_ADVANCED;
141 251 $SECTION_SEO_CONTROLS_INSTANT_INDEX = Metasync_Admin::SECTION_SEO_CONTROLS_INSTANT_INDEX;
142 252 $SECTION_PLUGIN_VISIBILITY = Metasync_Admin::SECTION_PLUGIN_VISIBILITY;
143 - $SECTION_BREADCRUMBS = Metasync_Admin::SECTION_BREADCRUMBS;
144 253 $SECTION_LLMS_TXT = Metasync_Admin::SECTION_LLMS_TXT;
145 254
146 255 $option_key = Metasync_Admin::option_key;
147 256 $page_slug = Metasync_Admin::$page_slug;
@@ -154,8 +263,14 @@
154 263 Metasync_Admin::option_key,
155 264 array(self::instance(), 'sanitize')
156 265 );
157 266
267 + // Analytics consent is deliberately NOT registered in the shared option
268 + // group: options.php writes null for every registered-but-absent field,
269 + // so saving any other page in the group would silently flip consent to
270 + // 'no'. It is persisted only by the General tab AJAX save path, which
271 + // handles both opt-in and opt-out explicitly.
272 +
158 273 // LLMs.txt settings are stored in a dedicated option so MCP tools and
159 274 // the admin UI share one source of truth.
160 275 register_setting(
161 276 Metasync_Admin::option_group,
@@ -234,9 +349,9 @@
234 349 $value = isset($settings['max_posts']) ? (int) $settings['max_posts'] : 50;
235 350 echo '<div class="llms-txt-dependent">';
236 351 printf(
237 352 '<input type="number" id="llms_txt_max_posts" name="metasync_llms_txt_settings[max_posts]" min="1" max="500" value="%d" />',
238 - $value
353 + absint($value)
239 354 );
240 355 echo '</div>';
241 356 },
242 357 $page_slug . '_general',
@@ -307,9 +422,9 @@
307 422 $value = isset($settings['max_posts_full']) ? (int) $settings['max_posts_full'] : 25;
308 423 echo '<div class="llms-full-dependent">';
309 424 printf(
310 425 '<input type="number" id="llms_txt_max_posts_full" name="metasync_llms_txt_settings[max_posts_full]" min="1" max="500" value="%d" />',
311 - $value
426 + absint($value)
312 427 );
313 428 echo '<p class="description">' . esc_html__('Maximum posts for /llms-full.txt (lower than summary to protect server performance).', 'metasync') . '</p>';
314 429 echo '</div>';
315 430 },
@@ -345,12 +460,12 @@
345 460 'Disallow Empty Archives',
346 461 function() use ($option_key) {
347 462 $noindex_empty_archives = Metasync::get_option('seo_controls')['noindex_empty_archives'] ?? 'false';
348 463 printf(
349 - '<input type="checkbox" id="noindex_empty_archives" name="' . $option_key . '[seo_controls][noindex_empty_archives]" value="true" %s />',
464 + '<input type="checkbox" id="noindex_empty_archives" name="' . esc_attr($option_key) . '[seo_controls][noindex_empty_archives]" value="true" %s />',
350 465 isset($noindex_empty_archives) && $noindex_empty_archives == 'true' ? 'checked' : ''
351 466 );
352 - printf('<span class="description"><strong>Empty Archives:</strong> When checked, automatically adds noindex to category, tag, author, and format archive pages that have no posts. Once posts are added to these archives, they will automatically be allowed for indexing. This prevents thin content pages from being indexed.</span>');
467 + echo '<span class="description"><strong>Empty Archives:</strong> When checked, automatically adds noindex to category, tag, author, and format archive pages that have no posts. Once posts are added to these archives, they will automatically be allowed for indexing. This prevents thin content pages from being indexed.</span>';
353 468 },
354 469 $page_slug . '_seo-controls',
355 470 $SECTION_SEO_CONTROLS
356 471 );
@@ -360,12 +475,12 @@
360 475 'Disallow Date Archives',
361 476 function() use ($option_key) {
362 477 $index_date_archives = Metasync::get_option('seo_controls')['index_date_archives'] ?? 'false';
363 478 printf(
364 - '<input type="checkbox" id="index_date_archives" name="' . $option_key . '[seo_controls][index_date_archives]" value="true" %s />',
479 + '<input type="checkbox" id="index_date_archives" name="' . esc_attr($option_key) . '[seo_controls][index_date_archives]" value="true" %s />',
365 480 isset($index_date_archives) && $index_date_archives == 'true' ? 'checked' : ''
366 481 );
367 - printf('<span class="description"><strong>Date Archives:</strong> When checked, prevents search engines from indexing date-based archive pages (e.g., /2024/01/, /2024/01/15/). These pages often have thin content and can dilute your site\'s SEO value. Recommended for most sites.</span>');
482 + echo '<span class="description"><strong>Date Archives:</strong> When checked, prevents search engines from indexing date-based archive pages (e.g., /2024/01/, /2024/01/15/). These pages often have thin content and can dilute your site\'s SEO value. Recommended for most sites.</span>';
368 483 },
369 484 $page_slug . '_seo-controls',
370 485 $SECTION_SEO_CONTROLS
371 486 );
@@ -375,12 +490,12 @@
375 490 'Disallow Tag Archives',
376 491 function() use ($option_key) {
377 492 $index_tag_archives = Metasync::get_option('seo_controls')['index_tag_archives'] ?? 'false';
378 493 printf(
379 - '<input type="checkbox" id="index_tag_archives" name="' . $option_key . '[seo_controls][index_tag_archives]" value="true" %s />',
494 + '<input type="checkbox" id="index_tag_archives" name="' . esc_attr($option_key) . '[seo_controls][index_tag_archives]" value="true" %s />',
380 495 isset($index_tag_archives) && $index_tag_archives == 'true' ? 'checked' : ''
381 496 );
382 - printf('<span class="description"><strong>Tag Archives:</strong> When checked, prevents search engines from indexing tag archive pages (e.g., /tag/technology/). Useful if you have many low-quality tag pages or want to focus on category-based organization instead.</span>');
497 + echo '<span class="description"><strong>Tag Archives:</strong> When checked, prevents search engines from indexing tag archive pages (e.g., /tag/technology/). Useful if you have many low-quality tag pages or want to focus on category-based organization instead.</span>';
383 498 },
384 499 $page_slug . '_seo-controls',
385 500 $SECTION_SEO_CONTROLS
386 501 );
@@ -390,12 +505,12 @@
390 505 'Disallow Author Archives',
391 506 function() use ($option_key) {
392 507 $index_author_archives = Metasync::get_option('seo_controls')['index_author_archives'] ?? 'false';
393 508 printf(
394 - '<input type="checkbox" id="index_author_archives" name="' . $option_key . '[seo_controls][index_author_archives]" value="true" %s />',
509 + '<input type="checkbox" id="index_author_archives" name="' . esc_attr($option_key) . '[seo_controls][index_author_archives]" value="true" %s />',
395 510 isset($index_author_archives) && $index_author_archives == 'true' ? 'checked' : ''
396 511 );
397 - printf('<span class="description"><strong>Author Archives:</strong> When checked, prevents search engines from indexing author archive pages (e.g., /author/john-doe/). Recommended for single-author sites or when author pages don\'t provide unique value. Multi-author sites may want to keep this unchecked.</span>');
512 + echo '<span class="description"><strong>Author Archives:</strong> When checked, prevents search engines from indexing author archive pages (e.g., /author/john-doe/). Recommended for single-author sites or when author pages don\'t provide unique value. Multi-author sites may want to keep this unchecked.</span>';
398 513 },
399 514 $page_slug . '_seo-controls',
400 515 $SECTION_SEO_CONTROLS
401 516 );
@@ -405,12 +520,12 @@
405 520 'Disallow Format Archives',
406 521 function() use ($option_key) {
407 522 $index_format_archives = Metasync::get_option('seo_controls')['index_format_archives'] ?? 'false';
408 523 printf(
409 - '<input type="checkbox" id="index_format_archives" name="' . $option_key . '[seo_controls][index_format_archives]" value="true" %s />',
524 + '<input type="checkbox" id="index_format_archives" name="' . esc_attr($option_key) . '[seo_controls][index_format_archives]" value="true" %s />',
410 525 isset($index_format_archives) && $index_format_archives == 'true' ? 'checked' : ''
411 526 );
412 - printf('<span class="description"><strong>Format Archives:</strong> When checked, prevents search engines from indexing post format archive pages (e.g., /type/aside/, /type/gallery/). These are rarely useful for SEO and can create duplicate content issues. Recommended for most sites.</span>');
527 + echo '<span class="description"><strong>Format Archives:</strong> When checked, prevents search engines from indexing post format archive pages (e.g., /type/aside/, /type/gallery/). These are rarely useful for SEO and can create duplicate content issues. Recommended for most sites.</span>';
413 528 },
414 529 $page_slug . '_seo-controls',
415 530 $SECTION_SEO_CONTROLS
416 531 );
@@ -420,12 +535,12 @@
420 535 'Disallow Category Archives',
421 536 function() use ($option_key) {
422 537 $index_category_archives = Metasync::get_option('seo_controls')['index_category_archives'] ?? 'false';
423 538 printf(
424 - '<input type="checkbox" id="index_category_archives" name="' . $option_key . '[seo_controls][index_category_archives]" value="true" %s />',
539 + '<input type="checkbox" id="index_category_archives" name="' . esc_attr($option_key) . '[seo_controls][index_category_archives]" value="true" %s />',
425 540 isset($index_category_archives) && $index_category_archives == 'true' ? 'checked' : ''
426 541 );
427 - printf('<span class="description"><strong>Category Archives:</strong> When checked, prevents search engines from indexing category archive pages (e.g., /category/news/). This may be useful if your category pages have thin content or if you want to consolidate SEO value on main pages instead. Use with caution as category pages can be valuable for site organization.</span>');
542 + echo '<span class="description"><strong>Category Archives:</strong> When checked, prevents search engines from indexing category archive pages (e.g., /category/news/). This may be useful if your category pages have thin content or if you want to consolidate SEO value on main pages instead. Use with caution as category pages can be valuable for site organization.</span>';
428 543 },
429 544 $page_slug . '_seo-controls',
430 545 $SECTION_SEO_CONTROLS
431 546 );
@@ -435,9 +550,9 @@
435 550 'Add No-follow to External Links',
436 551 function() use ($option_key) {
437 552 $add_nofollow_to_external_links = Metasync::get_option('seo_controls')['add_nofollow_to_external_links'] ?? 'false';
438 553 printf(
439 - '<input type="checkbox" id="add_nofollow_to_external_links" name="' . $option_key . '[seo_controls][add_nofollow_to_external_links]" value="true" %s />',
554 + '<input type="checkbox" id="add_nofollow_to_external_links" name="' . esc_attr($option_key) . '[seo_controls][add_nofollow_to_external_links]" value="true" %s />',
440 555 isset($add_nofollow_to_external_links) && $add_nofollow_to_external_links == 'true' ? 'checked' : ''
441 556 );
442 557 printf('<span class="description"><strong>No-follow External Links:</strong> When checked, automatically adds <code>rel="nofollow"</code> attribute to all external links appearing in posts, pages, and other post types when rendered by %s. This tells search engines not to follow these links, which can help preserve your site\'s SEO value and prevent passing link juice to external sites.</span>', esc_html(Metasync::get_whitelabel_otto_name()));
443 558 },
@@ -463,12 +578,12 @@
463 578 'Override Other Plugins\' Robots Tags',
464 579 function() use ($option_key) {
465 580 $override_robots_tags = Metasync::get_option('seo_controls')['override_robots_tags'] ?? 'false';
466 581 printf(
467 - '<input type="checkbox" id="override_robots_tags" name="' . $option_key . '[seo_controls][override_robots_tags]" value="true" %s />',
582 + '<input type="checkbox" id="override_robots_tags" name="' . esc_attr($option_key) . '[seo_controls][override_robots_tags]" value="true" %s />',
468 583 isset($override_robots_tags) && $override_robots_tags == 'true' ? 'checked' : ''
469 584 );
470 - printf('<span class="description"><strong>Override Robots Tags:</strong> When checked, ' . Metasync_Settings_Registration::instance()->get_effective_menu_title() . ' will take precedence over robots meta tags from other SEO plugins (Yoast, Rank Math, All in One SEO, etc.). This removes their noindex tags when you want to allow indexing on archive pages. Only enable this if other plugins are conflicting with your indexation settings.</span>');
585 + echo '<span class="description"><strong>Override Robots Tags:</strong> When checked, ' . esc_html(Metasync_Settings_Registration::instance()->get_effective_menu_title()) . ' will take precedence over robots meta tags from other SEO plugins (Yoast, Rank Math, All in One SEO, etc.). This removes their noindex tags when you want to allow indexing on archive pages. Only enable this if other plugins are conflicting with your indexation settings.</span>';
471 586 },
472 587 $page_slug . '_seo-controls',
473 588 $SECTION_SEO_CONTROLS_ADVANCED
474 589 );
@@ -491,13 +606,13 @@
491 606 'Enable Google Instant Indexing',
492 607 function() use ($option_key) {
493 608 $enable_googleinstantindex = Metasync::get_option('seo_controls')['enable_googleinstantindex'] ?? 'false';
494 609 printf(
495 - '<input type="checkbox" id="enable_googleinstantindex" name="' . $option_key . '[seo_controls][enable_googleinstantindex]" value="true" %s />',
610 + '<input type="checkbox" id="enable_googleinstantindex" name="' . esc_attr($option_key) . '[seo_controls][enable_googleinstantindex]" value="true" %s />',
496 611 isset($enable_googleinstantindex) && $enable_googleinstantindex == 'true' ? 'checked' : ''
497 612 );
498 613 Metasync::render_tooltip_icon('enable_googleinstantindex', 'Turns on direct URL submission to Google. It only works after you add a service-account key below and verify your site in Search Console — set those up first.');
499 - printf('<span class="description" style="margin-left:8px;"><strong>Enable Instant Indexing:</strong> When checked, enables the Google Instant Indexing feature which allows you to submit URLs directly to Google for faster indexing. A new "Instant Indexing" menu item will appear in the navigation.</span>');
614 + echo '<span class="description" style="margin-left:8px;"><strong>Enable Instant Indexing:</strong> When checked, enables the Google Instant Indexing feature which allows you to submit URLs directly to Google for faster indexing. Configure it in the Google Instant Indexing section below.</span>';
500 615 },
501 616 $page_slug . '_seo-controls',
502 617 $SECTION_SEO_CONTROLS_INSTANT_INDEX
503 618 );
@@ -538,13 +653,13 @@
538 653 $enable_binginstantindex = 'true';
539 654 }
540 655
541 656 printf(
542 - '<input type="checkbox" id="enable_binginstantindex" name="' . $option_key . '[seo_controls][enable_binginstantindex]" value="true" %s %s />',
657 + '<input type="checkbox" id="enable_binginstantindex" name="' . esc_attr($option_key) . '[seo_controls][enable_binginstantindex]" value="true" %s %s />',
543 658 isset($enable_binginstantindex) && $enable_binginstantindex == 'true' ? 'checked' : '',
544 659 $has_api_key ? 'data-auto-enabled="true"' : ''
545 660 );
546 - printf('<span class="description"><strong>Enable Bing Instant Indexing:</strong> Automatically enabled when you configure an API key below. When enabled, this activates the IndexNow protocol to instantly notify Bing, Yandex, and other search engines about URL changes.</span>');
661 + echo '<span class="description"><strong>Enable Bing Instant Indexing:</strong> Automatically enabled when you configure an API key below. When enabled, this activates the IndexNow protocol to instantly notify Bing, Yandex, and other search engines about URL changes.</span>';
547 662 },
548 663 $page_slug . '_seo-controls',
549 664 'seo_controls_bing_instant_index'
550 665 );
@@ -580,14 +695,30 @@
580 695 * Note: OTTO SSR is always enabled by default when connected
581 696 */
582 697
583 698 # field to accept the otto pixel
699 + # Read-only: the UUID is populated only by the trusted Search
700 + # Atlas / heartbeat flows. The input stays in the DOM (readonly, like
701 + # the apikey/linkgraph_token fields) so metasync-admin.js can keep
702 + # targeting it by name to reflect connect/disconnect state, and the
703 + # server rejects any submitted value regardless.
584 704 add_settings_field(
585 705 'otto_pixel_uuid',
586 706 $whitelabel_otto_name . ' Pixel UUID',
587 - function() use ($option_key) {
588 - $value = Metasync::get_option('general')['otto_pixel_uuid'] ?? '';
589 - printf('<input type="text" size="40" value = "'.esc_attr($value).'" name="' . $option_key . '[general][otto_pixel_uuid]"/>');
707 + function() use ($option_key, $whitelabel_otto_name) {
708 + $value = Metasync::get_option('general')['otto_pixel_uuid'] ?? '';
709 + printf(
710 + '<input type="text" id="otto_pixel_uuid" size="40" value="%s" name="%s" readonly="readonly" />',
711 + esc_attr($value),
712 + esc_attr($option_key . '[general][otto_pixel_uuid]')
713 + );
714 + printf(
715 + '<p class="description">%s</p>',
716 + esc_html(sprintf(
717 + 'Read-only. This UUID is assigned automatically by %s when you connect your account.',
718 + $whitelabel_otto_name
719 + ))
720 + );
590 721 },
591 722 $page_slug . '_general',
592 723 $SECTION_METASYNC
593 724 );
@@ -599,12 +730,12 @@
599 730 'Disable ' . $whitelabel_otto_name . ' for Logged in Users',
600 731 function() use ($whitelabel_otto_name, $option_key) {
601 732 $otto_disable_on_loggedin = Metasync::get_option('general')['otto_disable_on_loggedin'] ?? '';
602 733 printf(
603 - '<input type="checkbox" id="otto_disable_on_loggedin" name="' . $option_key . '[general][otto_disable_on_loggedin]" value="true" %s />',
734 + '<input type="checkbox" id="otto_disable_on_loggedin" name="' . esc_attr($option_key) . '[general][otto_disable_on_loggedin]" value="true" %s />',
604 735 isset($otto_disable_on_loggedin) && $otto_disable_on_loggedin == 'true' ? 'checked' : ''
605 736 );
606 - printf('<span class="description"> This disables '.$whitelabel_otto_name.' when logged in to allow editing original page contents</span>');
737 + echo '<span class="description"> This disables '.esc_html($whitelabel_otto_name).' when logged in to allow editing original page contents</span>';
607 738 },
608 739 $page_slug . '_general',
609 740 $SECTION_METASYNC
610 741 );
@@ -615,9 +746,9 @@
615 746 'Disable ' . $whitelabel_otto_name . ' Frontend Toolbar',
616 747 function() use ($whitelabel_otto_name, $option_key) {
617 748 $otto_disable_toolbar = Metasync::get_option('general')['otto_disable_preview_button'] ?? false;
618 749 printf(
619 - '<input type="checkbox" id="otto_disable_preview_button" name="' . $option_key . '[general][otto_disable_preview_button]" value="true" %s />',
750 + '<input type="checkbox" id="otto_disable_preview_button" name="' . esc_attr($option_key) . '[general][otto_disable_preview_button]" value="true" %s />',
620 751 filter_var($otto_disable_toolbar, FILTER_VALIDATE_BOOLEAN) ? 'checked' : ''
621 752 );
622 753 printf('<span class="description"> Hide the entire frontend toolbar (status indicator, preview button, and debug button) on the frontend. %s functionality will still work, but the toolbar controls will be hidden.</span>', esc_html($whitelabel_otto_name));
623 754 },
@@ -624,71 +755,123 @@
624 755 $page_slug . '_general',
625 756 $SECTION_METASYNC
626 757 );
627 758
628 - # WP Rocket compatibility mode setting
759 + # Global SEO title & description priority.
760 + #
761 + # Unchecked (the default) is the behaviour every site already runs: a
762 + # value the customer typed in the sidebar outranks OTTO's suggestion.
763 + # Checked hands precedence to OTTO, which is what makes a successful
764 + # OTTO deployment visible on a page that also holds a custom value.
765 + #
766 + # This only re-orders what RENDERS. Nothing is written to or deleted
767 + # from the stored post meta, so unchecking it restores the custom value
768 + # untouched. Applies to the SEO title, meta description and focus
769 + # keyword.
629 770 add_settings_field(
630 - 'otto_wp_rocket_compat',
631 - 'WP Rocket Compatibility',
771 + 'seo_priority',
772 + 'SEO Meta Priority',
632 773 function() use ($whitelabel_otto_name, $option_key) {
633 - $value = Metasync::get_option('general')['otto_wp_rocket_compat'] ?? 'auto';
634 - $wp_rocket_active = class_exists('WP_Rocket');
774 + $seo_priority = Metasync::get_option('general')['seo_priority'] ?? '';
775 + $checked = $seo_priority === Metasync_Seo_Precedence::PRIORITY_OTTO ? 'checked' : '';
776 + $name = $option_key . '[general][seo_priority]';
635 777
636 - echo '<select id="otto_wp_rocket_compat" name="' . $option_key . '[general][otto_wp_rocket_compat]">';
637 - echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
638 - echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>';
639 - echo '<option value="http"' . selected($value, 'http', false) . '>HTTP Mode (Safer)</option>';
640 - echo '<option value="disable_otto"' . selected($value, 'disable_otto', false) . '>Disable ' . esc_html($whitelabel_otto_name) . ' when WP Rocket is active</option>';
641 - echo '</select>';
642 -
643 - if ($wp_rocket_active) {
644 - echo '<p class="description" style="color: #0073aa; margin-top: 8px;">✓ WP Rocket detected - compatibility mode active</p>';
645 - }
646 -
647 - echo '<p class="description" style="margin-top: 8px;">';
648 - echo '<strong>Auto (Recommended):</strong> Allows both ' . esc_html($whitelabel_otto_name) . ' and WP Rocket to work together. Does not set DONOTCACHEPAGE unless required (Brizy pages, SG Optimizer conflicts).<br>';
649 - echo '<strong>Buffer Mode:</strong> Forces output buffering method. Fastest but may conflict with some configurations.<br>';
650 - echo '<strong>HTTP Mode:</strong> Forces internal HTTP fetch method. Slower but more compatible.<br>';
651 - echo '<strong>Disable ' . esc_html($whitelabel_otto_name) . ':</strong> Completely disables ' . esc_html($whitelabel_otto_name) . ' when WP Rocket is detected. Use if issues persist.';
652 - echo '</p>';
778 + echo '<div class="metasync-seo-priority-control">';
779 + printf(
780 + '<label for="seo_priority"><input type="checkbox" id="seo_priority" name="%s" value="otto" %s /> <strong>Prioritize approved %s values</strong></label>',
781 + esc_attr($name),
782 + esc_attr($checked),
783 + esc_html($whitelabel_otto_name)
784 + );
785 + printf(
786 + '<p class="description" id="seo_priority_description"><strong>Off:</strong> Your custom SEO title, description and focus keyword — the values you edit on the post/page edit screen — appear first; %1$s fills in only where you have no value.<br><strong>On:</strong> Approved %1$s values appear first; your custom values remain the fallback.<br><span class="metasync-seo-priority-note">Your saved values are never deleted or overwritten.</span></p>',
787 + esc_html($whitelabel_otto_name)
788 + );
789 + echo '</div>';
653 790 },
654 791 $page_slug . '_general',
655 792 $SECTION_METASYNC
656 793 );
657 794
658 - # SiteGround Optimizer compatibility mode setting
659 - add_settings_field(
660 - 'otto_sg_optimizer_compat',
661 - 'SiteGround Optimizer Compatibility',
662 - function() use ($option_key) {
663 - $value = Metasync::get_option('general')['otto_sg_optimizer_compat'] ?? 'auto';
664 - $sg_optimizer_active = defined('SiteGround_Optimizer\VERSION') || class_exists('SiteGround_Optimizer\Parser\Parser');
795 + # ───────────────────────────────────────────────────────────────
796 + # Plugin-compatibility toggles live on the dedicated Compatibility
797 + # page (admin.php?page=<slug>-compatibility), not the General tab.
798 + # Each toggle is registered only while its related plugin is active,
799 + # so the page never offers a control for a plugin that is not
800 + # present. The stored value survives while the field is hidden —
801 + # the save loop is isset-guarded and keeps absent keys.
802 + # ───────────────────────────────────────────────────────────────
803 + $compat_page_slug = $page_slug . '_compatibility';
665 804
666 - echo '<select id="otto_sg_optimizer_compat" name="' . $option_key . '[general][otto_sg_optimizer_compat]">';
667 - echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
668 - echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>';
669 - echo '<option value="http"' . selected($value, 'http', false) . '>HTTP Mode (Safer)</option>';
670 - echo '</select>';
805 + add_settings_section(
806 + 'metasync_compat_toggles',
807 + '',
808 + function() {},
809 + $compat_page_slug
810 + );
671 811
672 - if ($sg_optimizer_active) {
812 + # WP Rocket compatibility toggle.
813 + # Only registered when WP Rocket is actually present.
814 + # WP Rocket defines WP_ROCKET_VERSION; a WP_Rocket class only exists in
815 + # some builds, so check both.
816 + if (defined('WP_ROCKET_VERSION') || class_exists('WP_Rocket')) {
817 + add_settings_field(
818 + 'otto_wp_rocket_compat',
819 + 'WP Rocket Compatibility',
820 + function() use ($whitelabel_otto_name, $option_key) {
821 + $value = Metasync::get_option('general')['otto_wp_rocket_compat'] ?? 'auto';
822 +
823 + echo '<select id="otto_wp_rocket_compat" name="' . esc_attr($option_key) . '[general][otto_wp_rocket_compat]">';
824 + echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
825 + echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>';
826 + echo '<option value="http"' . selected($value, 'http', false) . '>HTTP Mode (Safer)</option>';
827 + echo '<option value="disable_otto"' . selected($value, 'disable_otto', false) . '>Disable ' . esc_html($whitelabel_otto_name) . ' when WP Rocket is active</option>';
828 + echo '</select>';
829 +
830 + echo '<p class="description" style="color: #0073aa; margin-top: 8px;">✓ WP Rocket detected - compatibility mode active</p>';
831 +
832 + echo '<p class="description" style="margin-top: 8px;">';
833 + echo '<strong>Auto (Recommended):</strong> Allows both ' . esc_html($whitelabel_otto_name) . ' and WP Rocket to work together. Does not set DONOTCACHEPAGE unless required (Brizy pages, SG Optimizer conflicts).<br>';
834 + echo '<strong>Buffer Mode:</strong> Forces output buffering method. Fastest but may conflict with some configurations.<br>';
835 + echo '<strong>HTTP Mode:</strong> Forces internal HTTP fetch method. Slower but more compatible.<br>';
836 + echo '<strong>Disable ' . esc_html($whitelabel_otto_name) . ':</strong> Completely disables ' . esc_html($whitelabel_otto_name) . ' when WP Rocket is detected. Use if issues persist.';
837 + echo '</p>';
838 + },
839 + $compat_page_slug,
840 + 'metasync_compat_toggles'
841 + );
842 + }
843 +
844 + # SiteGround Optimizer compatibility toggle.
845 + # Only registered when SiteGround Optimizer is actually present.
846 + if (defined('SiteGround_Optimizer\VERSION') || class_exists('SiteGround_Optimizer\Parser\Parser')) {
847 + add_settings_field(
848 + 'otto_sg_optimizer_compat',
849 + 'SiteGround Optimizer Compatibility',
850 + function() use ($option_key) {
851 + $value = Metasync::get_option('general')['otto_sg_optimizer_compat'] ?? 'auto';
852 +
853 + echo '<select id="otto_sg_optimizer_compat" name="' . esc_attr($option_key) . '[general][otto_sg_optimizer_compat]">';
854 + echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
855 + echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>';
856 + echo '</select>';
857 +
673 858 echo '<p class="description" style="color: #0073aa; margin-top: 8px;">✓ SiteGround Optimizer detected - compatibility mode active</p>';
674 - }
675 859
676 - echo '<p class="description" style="margin-top: 8px;">';
677 - echo '<strong>Auto (Recommended):</strong> Uses the internal HTTP fetch method with SG Optimizer, protecting themes that defer inline CSS to the footer (e.g. Divi).<br>';
678 - echo '<strong>Buffer Mode (Faster):</strong> Renders in-process with no internal page fetch, roughly halving uncached render time. Best when pages can never be host-cached anyway (e.g. a plugin starts a PHP session on every request — though MetaSync already removes that automatically for BookingPress). May conflict with Divi-style themes.<br>';
679 - echo '<strong>HTTP Mode:</strong> Forces the internal HTTP fetch method. Slower but more compatible.';
680 - echo '</p>';
681 - },
682 - $page_slug . '_general',
683 - $SECTION_METASYNC
684 - );
860 + echo '<p class="description" style="margin-top: 8px;">';
861 + echo '<strong>Auto (Recommended):</strong> Uses the internal HTTP fetch method with SG Optimizer, protecting themes that defer inline CSS to the footer (e.g. Divi).<br>';
862 + echo '<strong>Buffer Mode (Faster):</strong> Renders in-process with no internal page fetch, roughly halving uncached render time. Best when pages can never be host-cached anyway (e.g. a plugin starts a PHP session on every request — though MetaSync already removes that automatically for BookingPress). May conflict with Divi-style themes.';
863 + echo '</p>';
864 + },
865 + $compat_page_slug,
866 + 'metasync_compat_toggles'
867 + );
868 + }
685 869
686 - # BookingPress front-end session compatibility setting.
870 + # BookingPress front-end session compatibility toggle.
687 871 # Only registered when BookingPress is actually present on the site:
688 872 # the shim no-ops without it, so the field would be noise for every
689 - # other install. The stored value survives while the field is hidden
690 - # (the save loop is isset-guarded and keeps absent keys).
873 + # other install.
691 874 if (class_exists('bookingpress_spam_protection')) {
692 875 add_settings_field(
693 876 'bookingpress_session_compat',
694 877 'BookingPress Session Compatibility',
@@ -694,9 +877,9 @@
694 877 'BookingPress Session Compatibility',
695 878 function() use ($option_key) {
696 879 $value = Metasync::get_option('general')['bookingpress_session_compat'] ?? 'auto';
697 880
698 - echo '<select id="bookingpress_session_compat" name="' . $option_key . '[general][bookingpress_session_compat]">';
881 + echo '<select id="bookingpress_session_compat" name="' . esc_attr($option_key) . '[general][bookingpress_session_compat]">';
699 882 echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
700 883 echo '<option value="off"' . selected($value, 'off', false) . '>Disabled</option>';
701 884 echo '</select>';
702 885
@@ -707,10 +890,10 @@
707 890 echo '<strong>Auto (Recommended):</strong> Removes the unnecessary session start on public pages so page caching can work.<br>';
708 891 echo '<strong>Disabled:</strong> Leaves BookingPress exactly as installed (no page caching while it stays active).';
709 892 echo '</p>';
710 893 },
711 - $page_slug . '_general',
712 - $SECTION_METASYNC
894 + $compat_page_slug,
895 + 'metasync_compat_toggles'
713 896 );
714 897 }
715 898
716 899 /**
@@ -724,9 +907,9 @@
724 907 'Disable ' . $whitelabel_otto_name . ' for Bot Traffic',
725 908 function() use ($whitelabel_otto_name, $option_key) {
726 909 $otto_disable_for_bots = Metasync::get_option('general')['otto_disable_for_bots'] ?? false;
727 910 printf(
728 - '<input type="checkbox" id="otto_disable_for_bots" name="' . $option_key . '[general][otto_disable_for_bots]" value="true" %s />',
911 + '<input type="checkbox" id="otto_disable_for_bots" name="' . esc_attr($option_key) . '[general][otto_disable_for_bots]" value="true" %s />',
729 912 filter_var($otto_disable_for_bots, FILTER_VALIDATE_BOOLEAN) ? 'checked' : ''
730 913 );
731 914 printf('<span class="description"> Skip %s processing for detected bots (search engines, crawlers, SEO tools) to reduce unnecessary API calls. View bot statistics in the Bot Statistics page under the SEO dropdown.</span>', esc_html($whitelabel_otto_name));
732 915 },
@@ -740,9 +923,9 @@
740 923 'Bot Whitelist',
741 924 function() use ($whitelabel_otto_name, $option_key) {
742 925 $otto_bot_whitelist = Metasync::get_option('general')['otto_bot_whitelist'] ?? '';
743 926 printf(
744 - '<textarea id="otto_bot_whitelist" name="' . $option_key . '[general][otto_bot_whitelist]" rows="5" cols="50" style="width: 100%%; max-width: 500px; font-family: monospace;">%s</textarea>',
927 + '<textarea id="otto_bot_whitelist" name="' . esc_attr($option_key) . '[general][otto_bot_whitelist]" rows="5" cols="50" style="width: 100%%; max-width: 500px; font-family: monospace;">%s</textarea>',
745 928 esc_textarea($otto_bot_whitelist)
746 929 );
747 930 echo '<p class="description">';
748 931 echo 'Enter bot names or user-agent patterns (one per line) that should <strong>always</strong> be processed by ' . esc_html($whitelabel_otto_name) . ', even when "Disable for Bots" is enabled.<br>';
@@ -761,9 +944,9 @@
761 944 'Bot Blacklist',
762 945 function() use ($whitelabel_otto_name, $option_key) {
763 946 $otto_bot_blacklist = Metasync::get_option('general')['otto_bot_blacklist'] ?? '';
764 947 printf(
765 - '<textarea id="otto_bot_blacklist" name="' . $option_key . '[general][otto_bot_blacklist]" rows="5" cols="50" style="width: 100%%; max-width: 500px; font-family: monospace;">%s</textarea>',
948 + '<textarea id="otto_bot_blacklist" name="' . esc_attr($option_key) . '[general][otto_bot_blacklist]" rows="5" cols="50" style="width: 100%%; max-width: 500px; font-family: monospace;">%s</textarea>',
766 949 esc_textarea($otto_bot_blacklist)
767 950 );
768 951 echo '<p class="description">';
769 952 echo 'Enter bot names or user-agent patterns (one per line) that should <strong>always</strong> be blocked from ' . esc_html($whitelabel_otto_name) . ' processing, regardless of other settings.<br>';
@@ -785,9 +968,9 @@
785 968 printf(
786 969 '<a href="%s" class="button button-secondary"><span class="dashicons dashicons-chart-bar" style="margin-top:3px;font-size:15px;width:15px;height:15px;"></span> View Bot Statistics</a>',
787 970 esc_url(admin_url('admin.php?page=' . $page_slug . '-bot-statistics'))
788 971 );
789 - printf('<p class="description">View detailed bot detection statistics, breakdown by bot type, and unique bot entries with hit counts.</p>');
972 + echo '<p class="description">View detailed bot detection statistics, breakdown by bot type, and unique bot entries with hit counts.</p>';
790 973 },
791 974 $page_slug . '_general',
792 975 $SECTION_METASYNC
793 976 );
@@ -798,22 +981,20 @@
798 981 'periodic_clear_ottopage_cache',
799 982 'Clear Page Cache',
800 983 function() use ($option_key) {
801 984 $periodic_clear_ottopage_cache = Metasync::get_option('general')['periodic_clear_ottopage_cache'] ?? 'default';
802 - printf('<select style = "width : 250px" name="' . $option_key . '[general][periodic_clear_ottopage_cache]" id="heading_style">');
803 - printf('<option value="24" '. selected($periodic_clear_ottopage_cache, '24', false) . '>Clear Daily</option>');
804 - printf('<option value="36" '. selected($periodic_clear_ottopage_cache, '36', false) . '>Clear Every 2 days</option>');
805 - printf('<option value="40" '. selected($periodic_clear_ottopage_cache, '40', false) . '>Clear Weekly</option>');
806 - printf('<option value="0"'.selected($periodic_clear_ottopage_cache, '0', false).'>Clear Monthly (Default)</option>');
807 - printf('</select>');
985 + echo '<select style = "width : 250px" name="' . esc_attr($option_key) . '[general][periodic_clear_ottopage_cache]" id="heading_style">';
986 + echo '<option value="24" '. selected($periodic_clear_ottopage_cache, '24', false) . '>Clear Daily</option>';
987 + echo '<option value="36" '. selected($periodic_clear_ottopage_cache, '36', false) . '>Clear Every 2 days</option>';
988 + echo '<option value="40" '. selected($periodic_clear_ottopage_cache, '40', false) . '>Clear Weekly</option>';
989 + echo '<option value="0"'.selected($periodic_clear_ottopage_cache, '0', false).'>Clear Monthly (Default)</option>';
990 + echo '</select>';
808 991
809 992 # get last cleared timestamp
810 993 $timestamp = get_option('metasync_refresh_all_caches')['pages'] ?? false;
811 994
812 995 if($timestamp > 0){
813 - printf(
814 - '<p class="descriptionValue">last Cleared : '.date('y-m-d H:i:s', $timestamp).'</p>'
815 - );
996 + echo '<p class="descriptionValue">last Cleared : '.esc_html(gmdate('y-m-d H:i:s', $timestamp)).'</p>';
816 997 }
817 998
818 999 },
819 1000 $page_slug . '_otto_cache',
@@ -824,22 +1005,20 @@
824 1005 'periodic_clear_ottopost_cache',
825 1006 'Clear Post Cache',
826 1007 function() use ($option_key) {
827 1008 $periodic_clear_ottopost_cache = Metasync::get_option('general')['periodic_clear_ottopost_cache'] ?? 'default';
828 - printf('<select style = "width : 250px" name="' . $option_key . '[general][periodic_clear_ottopost_cache]" id="heading_style">');
829 - printf('<option value="24" '. selected($periodic_clear_ottopost_cache, '24', false) . '>Clear Daily</option>');
830 - printf('<option value="36" '. selected($periodic_clear_ottopost_cache, '36', false) . '>Clear Every 2 days</option>');
831 - printf('<option value="40" '. selected($periodic_clear_ottopost_cache, '40', false) . '>Clear Weekly</option>');
832 - printf('<option value="0"'.selected($periodic_clear_ottopost_cache, '0', false).'>Clear Monthly (Default)</option>');
833 - printf('</select>');
1009 + echo '<select style = "width : 250px" name="' . esc_attr($option_key) . '[general][periodic_clear_ottopost_cache]" id="heading_style">';
1010 + echo '<option value="24" '. selected($periodic_clear_ottopost_cache, '24', false) . '>Clear Daily</option>';
1011 + echo '<option value="36" '. selected($periodic_clear_ottopost_cache, '36', false) . '>Clear Every 2 days</option>';
1012 + echo '<option value="40" '. selected($periodic_clear_ottopost_cache, '40', false) . '>Clear Weekly</option>';
1013 + echo '<option value="0"'.selected($periodic_clear_ottopost_cache, '0', false).'>Clear Monthly (Default)</option>';
1014 + echo '</select>';
834 1015
835 1016 # get last cleared timestamp
836 1017 $timestamp = get_option('metasync_refresh_all_caches')['posts'] ?? false;
837 1018
838 1019 if($timestamp > 0){
839 - printf(
840 - '<p class="descriptionValue">last Cleared : '.date('y-m-d H:i:s', $timestamp).'</p>'
841 - );
1020 + echo '<p class="descriptionValue">last Cleared : '.esc_html(gmdate('y-m-d H:i:s', $timestamp)).'</p>';
842 1021 }
843 1022 },
844 1023 $page_slug . '_otto_cache',
845 1024 $SECTION_METASYNC
@@ -849,22 +1028,20 @@
849 1028 'periodic_clear_otto_cache',
850 1029 'Clear all cache',
851 1030 function() use ($option_key) {
852 1031 $periodic_clear_otto_cache = Metasync::get_option('general')['periodic_clear_otto_cache'] ?? 'default';
853 - printf('<select style = "width : 250px" name="' . $option_key . '[general][periodic_clear_otto_cache]" id="heading_style">');
854 - printf('<option value="24" '. selected($periodic_clear_otto_cache, '24', false) . '>Clear Daily</option>');
855 - printf('<option value="36" '. selected($periodic_clear_otto_cache, '36', false) . '>Clear Every 2 days</option>');
856 - printf('<option value="40" '. selected($periodic_clear_otto_cache, '40', false) . '>Clear Weekly</option>');
857 - printf('<option value="0"'.selected($periodic_clear_otto_cache, '0', false).'>Clear Monthly (Default)</option>');
858 - printf('</select>');
1032 + echo '<select style = "width : 250px" name="' . esc_attr($option_key) . '[general][periodic_clear_otto_cache]" id="heading_style">';
1033 + echo '<option value="24" '. selected($periodic_clear_otto_cache, '24', false) . '>Clear Daily</option>';
1034 + echo '<option value="36" '. selected($periodic_clear_otto_cache, '36', false) . '>Clear Every 2 days</option>';
1035 + echo '<option value="40" '. selected($periodic_clear_otto_cache, '40', false) . '>Clear Weekly</option>';
1036 + echo '<option value="0"'.selected($periodic_clear_otto_cache, '0', false).'>Clear Monthly (Default)</option>';
1037 + echo '</select>';
859 1038
860 1039 # get last cleared timestamp
861 1040 $timestamp = get_option('metasync_refresh_all_caches')['general'] ?? false;
862 1041
863 1042 if($timestamp > 0){
864 - printf(
865 - '<p class="descriptionValue">last Cleared : '.date('y-m-d H:i:s', $timestamp).'</p>'
866 - );
1043 + echo '<p class="descriptionValue">last Cleared : '.esc_html(gmdate('y-m-d H:i:s', $timestamp)).'</p>';
867 1044 }
868 1045
869 1046 },
870 1047 $page_slug . '_otto_cache',
@@ -879,12 +1056,12 @@
879 1056 'Disable Common Robots Meta Box',
880 1057 function() use ($option_key) {
881 1058 $disabled = Metasync::get_option('general')['disable_common_robots_metabox'] ?? false;
882 1059 printf(
883 - '<input type="checkbox" id="disable_common_robots_metabox" name="' . $option_key . '[general][disable_common_robots_metabox]" value="1" %s />',
1060 + '<input type="checkbox" id="disable_common_robots_metabox" name="' . esc_attr($option_key) . '[general][disable_common_robots_metabox]" value="1" %s />',
884 1061 $disabled ? 'checked' : ''
885 1062 );
886 - printf('<span class="description"> Hide the Common Robots Meta box on post/page edit screens</span>');
1063 + printf('<span class="description"> Hide the Common Robots meta box and stop %1$s emitting index/follow robots directives on the front end. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
887 1064 },
888 1065 $page_slug . '_general',
889 1066 $SECTION_METASYNC
890 1067 );
@@ -894,12 +1071,12 @@
894 1071 'Disable Advance Robots Meta Box',
895 1072 function() use ($option_key) {
896 1073 $disabled = Metasync::get_option('general')['disable_advance_robots_metabox'] ?? false;
897 1074 printf(
898 - '<input type="checkbox" id="disable_advance_robots_metabox" name="' . $option_key . '[general][disable_advance_robots_metabox]" value="1" %s />',
1075 + '<input type="checkbox" id="disable_advance_robots_metabox" name="' . esc_attr($option_key) . '[general][disable_advance_robots_metabox]" value="1" %s />',
899 1076 $disabled ? 'checked' : ''
900 1077 );
901 - printf('<span class="description"> Hide the Advance Robots Meta box on post/page edit screens</span>');
1078 + printf('<span class="description"> Hide the Advance Robots meta box and stop %1$s emitting max-snippet, max-image-preview and max-video-preview directives. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
902 1079 },
903 1080 $page_slug . '_general',
904 1081 $SECTION_METASYNC
905 1082 );
@@ -909,12 +1086,12 @@
909 1086 'Disable Redirection Meta Box',
910 1087 function() use ($option_key) {
911 1088 $disabled = Metasync::get_option('general')['disable_redirection_metabox'] ?? false;
912 1089 printf(
913 - '<input type="checkbox" id="disable_redirection_metabox" name="' . $option_key . '[general][disable_redirection_metabox]" value="1" %s />',
1090 + '<input type="checkbox" id="disable_redirection_metabox" name="' . esc_attr($option_key) . '[general][disable_redirection_metabox]" value="1" %s />',
914 1091 $disabled ? 'checked' : ''
915 1092 );
916 - printf('<span class="description"> Hide the Redirection meta box on post/page edit screens</span>');
1093 + echo '<span class="description"> Hide the Redirection meta box and stop redirects created by it from running. Rules added on the Redirections screen are unaffected. Saved redirects are kept.</span>';
917 1094 },
918 1095 $page_slug . '_general',
919 1096 $SECTION_METASYNC
920 1097 );
@@ -924,12 +1101,12 @@
924 1101 'Disable Canonical Meta Box',
925 1102 function() use ($option_key) {
926 1103 $disabled = Metasync::get_option('general')['disable_canonical_metabox'] ?? false;
927 1104 printf(
928 - '<input type="checkbox" id="disable_canonical_metabox" name="' . $option_key . '[general][disable_canonical_metabox]" value="1" %s />',
1105 + '<input type="checkbox" id="disable_canonical_metabox" name="' . esc_attr($option_key) . '[general][disable_canonical_metabox]" value="1" %s />',
929 1106 $disabled ? 'checked' : ''
930 1107 );
931 - printf('<span class="description"> Hide the Canonical meta box on post/page edit screens</span>');
1108 + printf('<span class="description"> Hide the Canonical meta box and stop %1$s emitting or overriding the canonical URL. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
932 1109 },
933 1110 $page_slug . '_general',
934 1111 $SECTION_METASYNC
935 1112 );
@@ -939,12 +1116,12 @@
939 1116 'Disable Social Media & Open Graph Meta Box',
940 1117 function() use ($option_key) {
941 1118 $disabled = Metasync::get_option('general')['disable_social_opengraph_metabox'] ?? false;
942 1119 printf(
943 - '<input type="checkbox" id="disable_social_opengraph_metabox" name="' . $option_key . '[general][disable_social_opengraph_metabox]" value="1" %s />',
1120 + '<input type="checkbox" id="disable_social_opengraph_metabox" name="' . esc_attr($option_key) . '[general][disable_social_opengraph_metabox]" value="1" %s />',
944 1121 $disabled ? 'checked' : ''
945 1122 );
946 - printf('<span class="description"> Hide the Social Media & Open Graph meta box on post/page edit screens</span>');
1123 + printf('<span class="description"> Hide the Social Media &amp; Open Graph meta box and stop %1$s emitting Open Graph and Twitter tags. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
947 1124 },
948 1125 $page_slug . '_general',
949 1126 $SECTION_METASYNC
950 1127 );
@@ -954,12 +1131,12 @@
954 1131 'Disable Schema Markup Meta Box',
955 1132 function() use ($option_key) {
956 1133 $disabled = Metasync::get_option('general')['disable_schema_markup_metabox'] ?? false;
957 1134 printf(
958 - '<input type="checkbox" id="disable_schema_markup_metabox" name="' . $option_key . '[general][disable_schema_markup_metabox]" value="1" %s />',
1135 + '<input type="checkbox" id="disable_schema_markup_metabox" name="' . esc_attr($option_key) . '[general][disable_schema_markup_metabox]" value="1" %s />',
959 1136 $disabled ? 'checked' : ''
960 1137 );
961 - printf('<span class="description"> Hide the Schema Markup meta box on post/page edit screens</span>');
1138 + printf('<span class="description"> Hide the Schema Markup meta box and stop %1$s emitting any JSON-LD, including site-wide Local SEO markup. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
962 1139 },
963 1140 $page_slug . '_general',
964 1141 $SECTION_METASYNC
965 1142 );
@@ -964,17 +1141,32 @@
964 1141 $SECTION_METASYNC
965 1142 );
966 1143
967 1144 add_settings_field(
1145 + 'disable_language_alternates_metabox',
1146 + 'Disable Language Alternates Meta Box',
1147 + function() use ($option_key) {
1148 + $disabled = Metasync::get_option('general')['disable_language_alternates_metabox'] ?? false;
1149 + printf(
1150 + '<input type="checkbox" id="disable_language_alternates_metabox" name="' . esc_attr($option_key) . '[general][disable_language_alternates_metabox]" value="1" %s />',
1151 + $disabled ? 'checked' : ''
1152 + );
1153 + printf('<span class="description"> Hide the Language Alternates (hreflang) panel in the block editor and stop %1$s emitting any %2$slink rel=&quot;alternate&quot; hreflang%3$s tags on the front end, including the auto-detected WPML entries. While disabled, %1$s does not manage or override this data — another SEO plugin, or WPML itself, is free to handle it. Saved values are kept and restored if you re-enable this.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')), '&lt;', '&gt;');
1154 + },
1155 + $page_slug . '_general',
1156 + $SECTION_METASYNC
1157 + );
1158 +
1159 + add_settings_field(
968 1160 'disable_seo_metabox',
969 1161 'Disable SEO Meta Box',
970 1162 function() use ($option_key) {
971 1163 $disabled = Metasync::get_option('general')['disable_seo_metabox'] ?? false;
972 1164 printf(
973 - '<input type="checkbox" id="disable_seo_metabox" name="' . $option_key . '[general][disable_seo_metabox]" value="1" %s />',
1165 + '<input type="checkbox" id="disable_seo_metabox" name="' . esc_attr($option_key) . '[general][disable_seo_metabox]" value="1" %s />',
974 1166 $disabled ? 'checked' : ''
975 1167 );
976 - printf('<span class="description"> Hide the SEO Title & Meta Description meta box on post/page edit screens</span>');
1168 + echo '<span class="description"> Hide the SEO Title & Meta Description meta box on post/page edit screens</span>';
977 1169 },
978 1170 $page_slug . '_general',
979 1171 $SECTION_METASYNC
980 1172 );
@@ -984,9 +1176,9 @@
984 1176 'Open External Links in New Tab/Window',
985 1177 function() use ($option_key) {
986 1178 $enabled = Metasync::get_option('general')['open_external_links'] ?? false;
987 1179 printf(
988 - '<input type="checkbox" id="open_external_links" name="' . $option_key . '[general][open_external_links]" value="1" %s />',
1180 + '<input type="checkbox" id="open_external_links" name="' . esc_attr($option_key) . '[general][open_external_links]" value="1" %s />',
989 1181 $enabled ? 'checked' : ''
990 1182 );
991 1183 printf('<span class="description"> Automatically add <code>target="_blank"</code> attribute to external links appearing in your posts, pages, and other post types when rendered by %s. The attribute is applied when the url is displayed.</span>', esc_html(Metasync::get_whitelabel_otto_name()));
992 1184 },
@@ -1002,11 +1194,11 @@
1002 1194 $current_permalink_structure = get_option('permalink_structure');
1003 1195 $current_rewrite_rules = get_option('rewrite_rules');
1004 1196 // Check if the current permalink structure is set to "Plain"
1005 1197 if (($current_permalink_structure == '/%post_id%/' || $current_permalink_structure == '') && $current_rewrite_rules == '') {
1006 - printf('<span class="description" style="color:#ff0000;opacity:1;">To ensure compatibility, Please Update your Permalink structure to any option other than "plain. For any Inquiries contact support <a href="' . get_admin_url() . 'options-permalink.php">Check Setting</a> </span>');
1198 + echo '<span class="description" style="color:#ff0000;opacity:1;">To ensure compatibility, Please Update your Permalink structure to any option other than "plain. For any Inquiries contact support <a href="' . esc_url(get_admin_url() . 'options-permalink.php') . '">Check Setting</a> </span>';
1007 1199 } else {
1008 - printf('<span class="description" style="color:#008000;opacity:1;">Permalink is Okay </span>');
1200 + echo '<span class="description" style="color:#008000;opacity:1;">Permalink is Okay </span>';
1009 1201 }
1010 1202 },
1011 1203 $page_slug . '_general',
1012 1204 $SECTION_METASYNC
@@ -1018,9 +1210,9 @@
1018 1210 'Hide Dashboard',
1019 1211 function() use ($option_key) {
1020 1212 $hide_dashboard = Metasync::get_option('general')['hide_dashboard_framework'] ?? '';
1021 1213 printf(
1022 - '<input type="checkbox" id="hide_dashboard_framework" name="' . $option_key . '[general][hide_dashboard_framework]" value="true" %s />',
1214 + '<input type="checkbox" id="hide_dashboard_framework" name="' . esc_attr($option_key) . '[general][hide_dashboard_framework]" value="true" %s />',
1023 1215 isset($hide_dashboard) && $hide_dashboard == 'true' ? 'checked' : ''
1024 1216 );
1025 1217 printf('<span class="description"> Hide the %s dashboard</span>', esc_html(Metasync_Settings_Registration::instance()->get_effective_menu_title()));
1026 1218 },
@@ -1036,9 +1228,9 @@
1036 1228 'Show ' . $this->get_effective_menu_title() . ' Status in Admin Bar',
1037 1229 function() use ($option_key) {
1038 1230 $show_admin_bar = Metasync::get_option('general')['show_admin_bar_status'] ?? true;
1039 1231 printf(
1040 - '<input type="checkbox" id="show_admin_bar_status" name="' . $option_key . '[general][show_admin_bar_status]" value="true" %s />',
1232 + '<input type="checkbox" id="show_admin_bar_status" name="' . esc_attr($option_key) . '[general][show_admin_bar_status]" value="true" %s />',
1041 1233 $show_admin_bar ? 'checked' : ''
1042 1234 );
1043 1235 printf('<span class="description">Show the %s status indicator in the WordPress admin bar.</span>', esc_html(Metasync::get_effective_plugin_name()));
1044 1236 },
@@ -1045,24 +1237,8 @@
1045 1237 $page_slug . '_general',
1046 1238 $SECTION_METASYNC
1047 1239 );
1048 1240
1049 - # Adding the "Enable Auto-updates" setting
1050 - add_settings_field(
1051 - 'enable_auto_updates',
1052 - 'Enable Automatic Updates',
1053 - function() use ($option_key) {
1054 - $enable_auto_updates = Metasync::get_option('general')['enable_auto_updates'] ?? false;
1055 - printf(
1056 - '<input type="checkbox" id="enable_auto_updates" name="' . $option_key . '[general][enable_auto_updates]" value="true" %s />',
1057 - $enable_auto_updates ? 'checked' : ''
1058 - );
1059 - printf('<span class="description">Allow WordPress to automatically update this plugin when new versions are available.</span>');
1060 - },
1061 - $page_slug . '_general',
1062 - $SECTION_METASYNC
1063 - );
1064 -
1065 1241 # Add User Role Sync Setting
1066 1242 add_settings_field(
1067 1243 'content_genius_sync_roles',
1068 1244 'Content Genius User Roles to Sync',
@@ -1164,9 +1340,9 @@
1164 1340 echo '<select id="default_page_builder" name="' . esc_attr($option_key) . '[general][default_page_builder]" style="min-width: 300px;">';
1165 1341
1166 1342 foreach ($builders as $key => $builder) {
1167 1343 $is_detected = $builder['detected'];
1168 - $label = esc_html($builder['label']);
1344 + $label = $builder['label'];
1169 1345 if ($is_detected && $key !== 'gutenberg') {
1170 1346 $label .= ' — Detected';
1171 1347 } elseif (!$is_detected) {
1172 1348 $label .= ' — Not Detected';
@@ -1178,9 +1354,9 @@
1178 1354 '<option value="%s" %s%s>%s</option>',
1179 1355 esc_attr($key),
1180 1356 selected(empty($current) ? 'gutenberg' : $current, $key, false),
1181 1357 $is_detected ? '' : ' disabled',
1182 - $label
1358 + esc_html($label)
1183 1359 );
1184 1360 }
1185 1361
1186 1362 echo '</select>';
@@ -1229,9 +1405,9 @@
1229 1405 printf(
1230 1406 '<a href="%s" class="button button-secondary"><span class="dashicons dashicons-download" style="margin-top:3px;font-size:15px;width:15px;height:15px;"></span> Import from SEO Plugins</a>',
1231 1407 esc_url(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-import-external'))
1232 1408 );
1233 - printf('<p class="description">Import settings and data from other SEO plugins (Yoast, Rank Math, AIOSEO, etc).</p>');
1409 + echo '<p class="description">Import settings and data from other SEO plugins (Yoast, Rank Math, AIOSEO, etc).</p>';
1234 1410 },
1235 1411 $page_slug . '_general',
1236 1412 $SECTION_METASYNC
1237 1413 );
@@ -1246,10 +1422,10 @@
1246 1422 'white_label_plugin_name',
1247 1423 'Plugin Name' . Metasync::get_tooltip_icon_html('wl_plugin_name_tooltip', 'The name shown for this plugin throughout the WordPress admin — menus, page titles, and system messages. Replace it with your own brand name (max 18 characters).'),
1248 1424 function() use ($option_key) {
1249 1425 $value = Metasync::get_option('general')['white_label_plugin_name'] ?? '';
1250 - printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_name]" value="' . esc_attr($value) . '" maxlength="18" />');
1251 - printf('<p class="description">This name will be used for general plugin branding (WordPress menus, page titles, and system messages). Maximum 18 characters.</p>');
1426 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_name]" value="' . esc_attr($value) . '" maxlength="18" />';
1427 + echo '<p class="description">This name will be used for general plugin branding (WordPress menus, page titles, and system messages). Maximum 18 characters.</p>';
1252 1428 },
1253 1429 $page_slug . '_branding',
1254 1430 $SECTION_METASYNC
1255 1431 );
@@ -1258,9 +1434,9 @@
1258 1434 'whitelabel_otto_name',
1259 1435 'OTTO Name' . Metasync::get_tooltip_icon_html('wl_otto_name_tooltip', 'The name used wherever OTTO is mentioned in the plugin (e.g. "Enable [Name] Server Side Rendering"). Rename it to match your own product branding.'),
1260 1436 function() use ($option_key) {
1261 1437 $value = Metasync::get_option('general')['whitelabel_otto_name'] ?? '';
1262 - printf('<input type="text" name="' . $option_key . '[general][whitelabel_otto_name]" value="' . esc_attr($value) . '" />');
1438 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][whitelabel_otto_name]" value="' . esc_attr($value) . '" />';
1263 1439 $example_name = !empty($value) ? $value : 'OTTO';
1264 1440 printf('<p class="description">This name will be used for OTTO feature references (e.g., "Enable %s Server Side Rendering").</p>', esc_html($example_name));
1265 1441 },
1266 1442 $page_slug . '_branding',
@@ -1272,9 +1448,9 @@
1272 1448 'Logo (Light Theme)' . Metasync::get_tooltip_icon_html('wl_logo_light_tooltip', 'Your logo shown in the admin dashboard when it\'s set to light mode. Leave blank to use the default logo.'),
1273 1449 function() use ($option_key) {
1274 1450 $whitelabel_settings = Metasync::get_whitelabel_settings();
1275 1451 $value = $whitelabel_settings['logo_light'] ?? '';
1276 - printf('<input type="url" name="' . $option_key . '[whitelabel][logo_light]" value="' . esc_attr($value) . '" size="60" />');
1452 + echo '<input type="url" name="' . esc_attr($option_key) . '[whitelabel][logo_light]" value="' . esc_attr($value) . '" size="60" />';
1277 1453 printf('<p class="description">Displayed when the admin UI is in light mode. Leave blank to use the default %s logo.</p>', esc_html(Metasync::get_effective_plugin_name()));
1278 1454 },
1279 1455 $page_slug . '_branding',
1280 1456 $SECTION_METASYNC
@@ -1285,9 +1461,9 @@
1285 1461 'Logo (Dark Theme)' . Metasync::get_tooltip_icon_html('wl_logo_dark_tooltip', 'Your logo shown in the admin dashboard when it\'s set to dark mode. Leave blank to use the default logo.'),
1286 1462 function() use ($option_key) {
1287 1463 $whitelabel_settings = Metasync::get_whitelabel_settings();
1288 1464 $value = $whitelabel_settings['logo_dark'] ?? '';
1289 - printf('<input type="url" name="' . $option_key . '[whitelabel][logo_dark]" value="' . esc_attr($value) . '" size="60" />');
1465 + echo '<input type="url" name="' . esc_attr($option_key) . '[whitelabel][logo_dark]" value="' . esc_attr($value) . '" size="60" />';
1290 1466 printf('<p class="description">Displayed when the admin UI is in dark mode. Leave blank to use the default %s logo.</p>', esc_html(Metasync::get_effective_plugin_name()));
1291 1467 },
1292 1468 $page_slug . '_branding',
1293 1469 $SECTION_METASYNC
@@ -1298,10 +1474,10 @@
1298 1474 'Dashboard URL' . Metasync::get_tooltip_icon_html('wl_dashboard_url_tooltip', 'The web address of your own branded dashboard, shown to clients instead of the Search Atlas dashboard link. This is cosmetic only — it doesn\'t change where your data is actually stored.'),
1299 1475 function() use ($option_key) {
1300 1476 $whitelabel_settings = Metasync::get_whitelabel_settings();
1301 1477 $value = $whitelabel_settings['domain'] ?? '';
1302 - printf('<input type="url" name="' . $option_key . '[whitelabel][domain]" value="' . esc_attr($value) . '" size="60" />');
1303 - printf('<p class="description">Enter your whitelabel dashboard URL (e.g., https://yourdashboard.com). Used for branding purposes.</p>');
1478 + echo '<input type="url" name="' . esc_attr($option_key) . '[whitelabel][domain]" value="' . esc_attr($value) . '" size="60" />';
1479 + echo '<p class="description">Enter your whitelabel dashboard URL (e.g., https://yourdashboard.com). Used for branding purposes.</p>';
1304 1480 },
1305 1481 $page_slug . '_branding',
1306 1482 $SECTION_METASYNC
1307 1483 );
@@ -1309,9 +1485,9 @@
1309 1485 'white_label_plugin_description',
1310 1486 'Plugin Description',
1311 1487 function() use ($option_key) {
1312 1488 $value = Metasync::get_option('general')['white_label_plugin_description'] ?? '';
1313 - printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_description]" value="' . esc_attr($value) . '" />');
1489 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_description]" value="' . esc_attr($value) . '" />';
1314 1490 },
1315 1491 $page_slug . '_branding',
1316 1492 $SECTION_METASYNC
1317 1493 );
@@ -1320,9 +1496,9 @@
1320 1496 'white_label_plugin_author',
1321 1497 'Author',
1322 1498 function() use ($option_key) {
1323 1499 $value = Metasync::get_option('general')['white_label_plugin_author'] ?? '';
1324 - printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_author]" value="' . esc_attr($value) . '" />');
1500 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_author]" value="' . esc_attr($value) . '" />';
1325 1501 },
1326 1502 $page_slug . '_branding',
1327 1503 $SECTION_METASYNC
1328 1504 );
@@ -1331,9 +1507,9 @@
1331 1507 'white_label_plugin_author_uri',
1332 1508 'Author URI',
1333 1509 function() use ($option_key) {
1334 1510 $value = Metasync::get_option('general')['white_label_plugin_author_uri'] ?? '';
1335 - # printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_author_uri]" value="' . esc_attr($value) . '" />');
1511 + # printf('<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_author_uri]" value="' . esc_attr($value) . '" />');
1336 1512 # Fixed printf usage
1337 1513 printf('<input type="text" name="%s" value="%s" />', esc_attr($option_key . '[general][white_label_plugin_author_uri]'), esc_attr($value) );
1338 1514
1339 1515 },
@@ -1344,9 +1520,9 @@
1344 1520 'white_label_plugin_uri',
1345 1521 'Plugin URI',
1346 1522 function() use ($option_key) {
1347 1523 $value = Metasync::get_option('general')['white_label_plugin_uri'] ?? ''; // New option for Plugin URI
1348 - printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_uri]" value="' . esc_attr($value) . '" />');
1524 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_uri]" value="' . esc_attr($value) . '" />';
1349 1525 },
1350 1526 $page_slug . '_branding',
1351 1527 $SECTION_METASYNC
1352 1528 );
@@ -1360,10 +1536,10 @@
1360 1536 'white_label_plugin_menu_slug',
1361 1537 'Menu Slug',
1362 1538 function() use ($option_key) {
1363 1539 $value = Metasync::get_option('general')['white_label_plugin_menu_slug'] ?? '';
1364 - printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_menu_slug]" value="' . esc_attr($value) . '" placeholder="hip-advanced-seo" />');
1365 - printf('<p class="description">Short identifier for the plugin\'s admin URL &mdash; lowercase letters, numbers, and dashes only (e.g. <code>hip-advanced-seo</code>). Don\'t paste a full web address.</p>');
1540 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_menu_slug]" value="' . esc_attr($value) . '" placeholder="hip-advanced-seo" />';
1541 + echo '<p class="description">Short identifier for the plugin\'s admin URL &mdash; lowercase letters, numbers, and dashes only (e.g. <code>hip-advanced-seo</code>). Don\'t paste a full web address.</p>';
1366 1542 },
1367 1543 $page_slug . '_branding',
1368 1544 $SECTION_METASYNC
1369 1545 );
@@ -1371,9 +1547,9 @@
1371 1547 'white_label_plugin_menu_icon',
1372 1548 'Menu Icon',
1373 1549 function() use ($option_key) {
1374 1550 $value = Metasync::get_option('general')['white_label_plugin_menu_icon'] ?? '';
1375 - printf('<input type="text" name="' . $option_key . '[general][white_label_plugin_menu_icon]" value="' . esc_attr($value) . '" />');
1551 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_menu_icon]" value="' . esc_attr($value) . '" />';
1376 1552 },
1377 1553 $page_slug . '_branding',
1378 1554 $SECTION_METASYNC
1379 1555 );
@@ -1388,22 +1564,22 @@
1388 1564
1389 1565 // Output radio button for Default Style.css active
1390 1566
1391 1567 printf(
1392 - '<input type="radio" id="enable_default" name="' . $option_key . '[general][enabled_plugin_css]" value="default" %s />',
1568 + '<input type="radio" id="enable_default" name="' . esc_attr($option_key) . '[general][enabled_plugin_css]" value="default" %s />',
1393 1569 ($enabled_plugin_css == 'default'||$enabled_plugin_css =='') ? 'checked' : ''
1394 1570 );
1395 - printf('<label for="enable_default">Default</label><br>');
1571 + echo '<label for="enable_default">Default</label><br>';
1396 1572
1397 1573
1398 1574 // Output radio button for Metasync Style
1399 1575 printf(
1400 - '<input type="radio" id="enable_metasync" name="' . $option_key . '[general][enabled_plugin_css]" value="metasync" %s />',
1576 + '<input type="radio" id="enable_metasync" name="' . esc_attr($option_key) . '[general][enabled_plugin_css]" value="metasync" %s />',
1401 1577 ($enabled_plugin_css == 'metasync') ? 'checked' : ''
1402 1578 );
1403 - printf('<label for="enable_metasync">Metasync Style</label>');
1579 + echo '<label for="enable_metasync">Metasync Style</label>';
1404 1580
1405 - printf('<p class="description"> Choose the default page Style Sheet: Default or MetaSync.</p>');
1581 + echo '<p class="description"> Choose the default page Style Sheet: Default or MetaSync.</p>';
1406 1582 },
1407 1583 $page_slug . '_branding',
1408 1584 $SECTION_METASYNC
1409 1585 );
@@ -1413,12 +1589,12 @@
1413 1589 'enabled_elementor_plugin_css',
1414 1590 'Choose Elementor Font Style',
1415 1591 function() use ($option_key) {
1416 1592 $enabled_elementor_plugin_css = Metasync::get_option('general')['enabled_elementor_plugin_css'] ?? 'default';
1417 - printf('<select name="' . $option_key . '[general][enabled_elementor_plugin_css]" id="heading_style">');
1418 - printf('<option value="default"'.selected($enabled_elementor_plugin_css, 'default', false).'>Default</option>');
1419 - printf('<option value="custom" '. selected($enabled_elementor_plugin_css, 'custom', false) . '>Custom</option>');
1420 - printf('</select>');
1593 + echo '<select name="' . esc_attr($option_key) . '[general][enabled_elementor_plugin_css]" id="heading_style">';
1594 + echo '<option value="default"'.selected($enabled_elementor_plugin_css, 'default', false).'>Default</option>';
1595 + echo '<option value="custom" '. selected($enabled_elementor_plugin_css, 'custom', false) . '>Custom</option>';
1596 + echo '</select>';
1421 1597 },
1422 1598 $page_slug . '_branding',
1423 1599 $SECTION_METASYNC
1424 1600 );
@@ -1426,9 +1602,9 @@
1426 1602 'enabled_elementor_plugin_css_color',
1427 1603 'Choose Elementor Font Color',
1428 1604 function() use ($option_key) {
1429 1605 $enabled_elementor_plugin_css_color = Metasync::get_option('general')['enabled_elementor_plugin_css_color'] ?? '#000000';
1430 - printf('<input type="color" id="elementor_default_color_metasync" name="' . $option_key . '[general][enabled_elementor_plugin_css_color]" value="'.$enabled_elementor_plugin_css_color.'">');
1606 + echo '<input type="color" id="elementor_default_color_metasync" name="' . esc_attr($option_key) . '[general][enabled_elementor_plugin_css_color]" value="'.esc_attr($enabled_elementor_plugin_css_color).'">';
1431 1607 },
1432 1608 $page_slug . '_branding',
1433 1609 $SECTION_METASYNC
1434 1610 );
@@ -1439,10 +1615,10 @@
1439 1615 function() use ($option_key) {
1440 1616 // Decrypted for display — this field only renders after the
1441 1617 // whitelabel password gate has been passed.
1442 1618 $value = Metasync::get_whitelabel_password();
1443 - printf('<input type="password" name="' . $option_key . '[whitelabel][settings_password]" value="' . esc_attr($value) . '" size="30" autocomplete="new-password" />');
1444 - printf('<p class="description">Set a custom password to protect the branding settings section.</p>');
1619 + echo '<input type="password" name="' . esc_attr($option_key) . '[whitelabel][settings_password]" value="' . esc_attr($value) . '" size="30" autocomplete="new-password" />';
1620 + echo '<p class="description">Set a custom password to protect the branding settings section.</p>';
1445 1621 },
1446 1622 $page_slug . '_branding',
1447 1623 $SECTION_METASYNC
1448 1624 );
@@ -1453,10 +1629,10 @@
1453 1629 function() use ($option_key) {
1454 1630 $whitelabel_settings = Metasync::get_whitelabel_settings();
1455 1631 $value = $whitelabel_settings['recovery_email'] ?? '';
1456 1632 $has_password = !empty($whitelabel_settings['settings_password']);
1457 - printf('<input type="email" name="' . $option_key . '[whitelabel][recovery_email]" value="' . esc_attr($value) . '" size="30" autocomplete="email" %s />', $has_password ? 'required' : '');
1458 - printf('<p class="description">Email address to receive password recovery. <strong>Required when password is set.</strong></p>');
1633 + printf('<input type="email" name="' . esc_attr($option_key) . '[whitelabel][recovery_email]" value="' . esc_attr($value) . '" size="30" autocomplete="email" %s />', $has_password ? 'required' : '');
1634 + echo '<p class="description">Email address to receive password recovery. <strong>Required when password is set.</strong></p>';
1459 1635 },
1460 1636 $page_slug . '_branding',
1461 1637 $SECTION_METASYNC
1462 1638 );
@@ -1486,11 +1662,11 @@
1486 1662 <td><input type="text" name="%s[whitelabel][quick_links][%d][label]" value="%s" placeholder="Link label" style="width:100%%" /></td>
1487 1663 <td><input type="url" name="%s[whitelabel][quick_links][%d][url]" value="%s" placeholder="https://…" style="width:100%%" /></td>
1488 1664 <td style="text-align:center"><input type="checkbox" name="%s[whitelabel][quick_links][%d][external]" value="1" %s /></td>
1489 1665 </tr>',
1490 - esc_attr($option_key), $i, $label,
1491 - esc_attr($option_key), $i, $url,
1492 - esc_attr($option_key), $i, checked($external, true, false)
1666 + esc_attr($option_key), esc_attr($i), esc_attr($label),
1667 + esc_attr($option_key), esc_attr($i), esc_attr($url),
1668 + esc_attr($option_key), esc_attr($i), checked($external, true, false)
1493 1669 );
1494 1670 }
1495 1671 echo '</tbody></table>';
1496 1672 },
@@ -1517,9 +1693,9 @@
1517 1693 'Organization Name',
1518 1694 function() use ($option_key) {
1519 1695 $schema = Metasync::get_option('schema') ?? [];
1520 1696 $value = $schema['org_name'] ?? '';
1521 - printf('<input type="text" name="' . $option_key . '[schema][org_name]" value="%s" size="40" placeholder="Your organization name" />', esc_attr($value));
1697 + printf('<input type="text" name="' . esc_attr($option_key) . '[schema][org_name]" value="%s" size="40" placeholder="Your organization name" />', esc_attr($value));
1522 1698 },
1523 1699 $schema_page,
1524 1700 'metasync_schema_org'
1525 1701 );
@@ -1529,9 +1705,9 @@
1529 1705 'Organization URL',
1530 1706 function() use ($option_key) {
1531 1707 $schema = Metasync::get_option('schema') ?? [];
1532 1708 $value = $schema['org_url'] ?? '';
1533 - printf('<input type="url" name="' . $option_key . '[schema][org_url]" value="%s" size="40" placeholder="https://example.com" />', esc_attr($value));
1709 + printf('<input type="url" name="' . esc_attr($option_key) . '[schema][org_url]" value="%s" size="40" placeholder="https://example.com" />', esc_attr($value));
1534 1710 },
1535 1711 $schema_page,
1536 1712 'metasync_schema_org'
1537 1713 );
@@ -1541,9 +1717,9 @@
1541 1717 'Organization Logo URL' . Metasync::get_tooltip_icon_html('schema_org_logo_tooltip', 'A direct link to your logo image. Google uses this in your Organization knowledge panel — use a clear, square-ish image on a plain background, ideally at least 112x112 pixels.'),
1542 1718 function() use ($option_key) {
1543 1719 $schema = Metasync::get_option('schema') ?? [];
1544 1720 $value = $schema['org_logo'] ?? '';
1545 - printf('<input type="url" name="' . $option_key . '[schema][org_logo]" value="%s" size="40" placeholder="https://example.com/logo.png" />', esc_attr($value));
1721 + printf('<input type="url" name="' . esc_attr($option_key) . '[schema][org_logo]" value="%s" size="40" placeholder="https://example.com/logo.png" />', esc_attr($value));
1546 1722 },
1547 1723 $schema_page,
1548 1724 'metasync_schema_org'
1549 1725 );
@@ -1553,9 +1729,9 @@
1553 1729 'Contact Telephone',
1554 1730 function() use ($option_key) {
1555 1731 $schema = Metasync::get_option('schema') ?? [];
1556 1732 $value = $schema['org_contact_telephone'] ?? '';
1557 - printf('<input type="text" name="' . $option_key . '[schema][org_contact_telephone]" value="%s" size="30" placeholder="+1-555-1234" />', esc_attr($value));
1733 + printf('<input type="text" name="' . esc_attr($option_key) . '[schema][org_contact_telephone]" value="%s" size="30" placeholder="+1-555-1234" />', esc_attr($value));
1558 1734 },
1559 1735 $schema_page,
1560 1736 'metasync_schema_org'
1561 1737 );
@@ -1565,9 +1741,9 @@
1565 1741 'Contact Type' . Metasync::get_tooltip_icon_html('schema_org_contact_type_tooltip', 'What this phone number is for, using schema.org wording such as "customer support", "sales", or "technical support". It tells search engines how the number should be labeled.'),
1566 1742 function() use ($option_key) {
1567 1743 $schema = Metasync::get_option('schema') ?? [];
1568 1744 $value = $schema['org_contact_type'] ?? '';
1569 - printf('<input type="text" name="' . $option_key . '[schema][org_contact_type]" value="%s" size="30" placeholder="customer support" />', esc_attr($value));
1745 + printf('<input type="text" name="' . esc_attr($option_key) . '[schema][org_contact_type]" value="%s" size="30" placeholder="customer support" />', esc_attr($value));
1570 1746 },
1571 1747 $schema_page,
1572 1748 'metasync_schema_org'
1573 1749 );
@@ -1577,9 +1753,9 @@
1577 1753 'Social Profile URLs' . Metasync::get_tooltip_icon_html('schema_org_same_as_tooltip', 'Links to your official profiles on other sites (Facebook, X/Twitter, LinkedIn, Wikipedia, etc.), one per line. This is the schema.org "sameAs" property — it helps search engines confirm these accounts are really you.'),
1578 1754 function() use ($option_key) {
1579 1755 $schema = Metasync::get_option('schema') ?? [];
1580 1756 $value = $schema['org_same_as'] ?? '';
1581 - printf('<textarea name="' . $option_key . '[schema][org_same_as]" rows="4" cols="50" placeholder="One URL per line&#10;https://facebook.com/...&#10;https://twitter.com/...">%s</textarea>', esc_textarea($value));
1757 + printf('<textarea name="' . esc_attr($option_key) . '[schema][org_same_as]" rows="4" cols="50" placeholder="One URL per line&#10;https://facebook.com/...&#10;https://twitter.com/...">%s</textarea>', esc_textarea($value));
1582 1758 echo '<p class="description">Enter one social profile URL per line.</p>';
1583 1759 },
1584 1760 $schema_page,
1585 1761 'metasync_schema_org'
@@ -1599,9 +1775,9 @@
1599 1775 'WebSite Name',
1600 1776 function() use ($option_key) {
1601 1777 $schema = Metasync::get_option('schema') ?? [];
1602 1778 $value = $schema['website_name'] ?? '';
1603 - printf('<input type="text" name="' . $option_key . '[schema][website_name]" value="%s" size="40" placeholder="Your website name" />', esc_attr($value));
1779 + printf('<input type="text" name="' . esc_attr($option_key) . '[schema][website_name]" value="%s" size="40" placeholder="Your website name" />', esc_attr($value));
1604 1780 },
1605 1781 $schema_page,
1606 1782 'metasync_schema_website'
1607 1783 );
@@ -1611,9 +1787,9 @@
1611 1787 'WebSite URL',
1612 1788 function() use ($option_key) {
1613 1789 $schema = Metasync::get_option('schema') ?? [];
1614 1790 $value = $schema['website_url'] ?? '';
1615 - printf('<input type="url" name="' . $option_key . '[schema][website_url]" value="%s" size="40" placeholder="https://example.com" />', esc_attr($value));
1791 + printf('<input type="url" name="' . esc_attr($option_key) . '[schema][website_url]" value="%s" size="40" placeholder="https://example.com" />', esc_attr($value));
1616 1792 },
1617 1793 $schema_page,
1618 1794 'metasync_schema_website'
1619 1795 );
@@ -1624,9 +1800,9 @@
1624 1800 function() use ($option_key) {
1625 1801 $schema = Metasync::get_option('schema') ?? [];
1626 1802 $value = $schema['website_searchbox'] ?? false;
1627 1803 printf(
1628 - '<input type="checkbox" name="' . $option_key . '[schema][website_searchbox]" value="1" %s />',
1804 + '<input type="checkbox" name="' . esc_attr($option_key) . '[schema][website_searchbox]" value="1" %s />',
1629 1805 checked($value, true, false)
1630 1806 );
1631 1807 echo '<span class="description">Adds SearchAction to WebSite schema for Google Sitelinks Searchbox.</span>';
1632 1808 },
@@ -1649,9 +1825,9 @@
1649 1825 function() use ($option_key) {
1650 1826 $schema = Metasync::get_option('schema') ?? [];
1651 1827 $value = $schema['override_woocommerce_schema'] ?? false;
1652 1828 printf(
1653 - '<input type="checkbox" name="' . $option_key . '[schema][override_woocommerce_schema]" value="1" %s />',
1829 + '<input type="checkbox" name="' . esc_attr($option_key) . '[schema][override_woocommerce_schema]" value="1" %s />',
1654 1830 checked($value, true, false)
1655 1831 );
1656 1832 echo '<span class="description">When unchecked (default), MetaSync suppresses its Product schema when WooCommerce is active to avoid duplication. Check to override WooCommerce\'s native Product schema.</span>';
1657 1833 },
@@ -1705,9 +1881,9 @@
1705 1881 'baidu_site_verification',
1706 1882 'Baidu' . Metasync::get_tooltip_icon_html('baidu_site_verification_tooltip', 'Paste the verification code Baidu Webmaster gave you. This lets Baidu confirm you own this site — it does not change anything visitors see.'),
1707 1883 function() use ($option_key) {
1708 1884 $value = Metasync::get_option('searchengines')['baidu_site_verification'] ?? '';
1709 - printf( '<input type="text" id="baidu_site_verification" name="' . $option_key . '[searchengines][baidu_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1885 + printf( '<input type="text" id="baidu_site_verification" name="' . esc_attr($option_key) . '[searchengines][baidu_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1710 1886 },
1711 1887 $page_slug . '_searchengines-verification',
1712 1888 $SECTION_SEARCHENGINE
1713 1889 );
@@ -1716,9 +1892,9 @@
1716 1892 'alexa_site_verification',
1717 1893 'Alexa' . Metasync::get_tooltip_icon_html('alexa_site_verification_tooltip', 'Paste the verification code your Alexa/site directory gave you. This confirms you own this site — it does not change anything visitors see.'),
1718 1894 function() use ($option_key) {
1719 1895 $value = Metasync::get_option('searchengines')['alexa_site_verification'] ?? '';
1720 - printf( '<input type="text" id="alexa_site_verification" name="' . $option_key . '[searchengines][alexa_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1896 + printf( '<input type="text" id="alexa_site_verification" name="' . esc_attr($option_key) . '[searchengines][alexa_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1721 1897 },
1722 1898 $page_slug . '_searchengines-verification',
1723 1899 $SECTION_SEARCHENGINE
1724 1900 );
@@ -1727,9 +1903,9 @@
1727 1903 'norton_save_site_verification',
1728 1904 'Norton Safe Web' . Metasync::get_tooltip_icon_html('norton_site_verification_tooltip', 'Paste the verification code Norton Safe Web gave you. This confirms you own this site — it does not change anything visitors see.'),
1729 1905 function() use ($option_key) {
1730 1906 $value = Metasync::get_option('searchengines')['norton_save_site_verification'] ?? '';
1731 - printf( '<input type="text" id="norton_save_site_verification" name="' . $option_key . '[searchengines][norton_save_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1907 + printf( '<input type="text" id="norton_save_site_verification" name="' . esc_attr($option_key) . '[searchengines][norton_save_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1732 1908 },
1733 1909 $page_slug . '_searchengines-verification',
1734 1910 $SECTION_SEARCHENGINE
1735 1911 );
@@ -1788,121 +1964,14 @@
1788 1964 add_settings_field( 'local_seo_map_key', 'Google Maps Key' . Metasync::get_tooltip_icon_html('local_seo_map_key_tooltip', 'A Google Maps API key from your Google Cloud account. Only needed if you want an embedded map; it lets Google bill and authorize the map requests.'), array( $fields_instance, 'local_seo_map_key_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1789 1965 add_settings_field( 'local_seo_geo_coordinates', 'Geo Coordinates' . Metasync::get_tooltip_icon_html('local_seo_geo_coordinates_tooltip', 'The exact latitude and longitude of your business. This helps search engines and maps pin your location precisely, especially when your street address is ambiguous.'), array( $fields_instance, 'local_seo_geo_coordinates_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1790 1966
1791 1967 // ----------------------------------------------------------------
1792 - // Breadcrumbs page
1968 + // Breadcrumbs page — intentionally registers no Settings API fields.
1969 + // The dedicated Breadcrumbs admin page renders the richer controls
1970 + // directly via Metasync_Settings_Fields::render_breadcrumbs_section()
1971 + // and writes the same metasync_options[breadcrumbs] keys.
1793 1972 // ----------------------------------------------------------------
1794 - add_settings_section(
1795 - $SECTION_BREADCRUMBS,
1796 - '',
1797 - function() {},
1798 - $page_slug . '_breadcrumbs'
1799 - );
1800 1973
1801 - add_settings_field(
1802 - 'breadcrumbs_enabled',
1803 - 'Enable Breadcrumbs',
1804 - function() use ($option_key) {
1805 - $value = Metasync::get_option('breadcrumbs')['enabled'] ?? true;
1806 - printf(
1807 - '<input type="checkbox" id="breadcrumbs_enabled" name="' . $option_key . '[breadcrumbs][enabled]" value="1" %s />',
1808 - checked(1, $value, false)
1809 - );
1810 - echo '<p class="description">Output breadcrumb trail HTML on your site.</p>';
1811 - },
1812 - $page_slug . '_breadcrumbs',
1813 - $SECTION_BREADCRUMBS
1814 - );
1815 -
1816 - add_settings_field(
1817 - 'breadcrumbs_separator',
1818 - 'Separator',
1819 - function() use ($option_key) {
1820 - $value = Metasync::get_option('breadcrumbs')['separator'] ?? '&raquo;';
1821 - printf(
1822 - '<input type="text" id="breadcrumbs_separator" name="' . $option_key . '[breadcrumbs][separator]" value="%s" size="10" />',
1823 - esc_attr($value)
1824 - );
1825 - echo '<p class="description">Character or HTML entity shown between crumbs (e.g. &raquo; or /).</p>';
1826 - },
1827 - $page_slug . '_breadcrumbs',
1828 - $SECTION_BREADCRUMBS
1829 - );
1830 -
1831 - add_settings_field(
1832 - 'breadcrumbs_home_label',
1833 - 'Home Label',
1834 - function() use ($option_key) {
1835 - $value = Metasync::get_option('breadcrumbs')['home_label'] ?? 'Home';
1836 - printf(
1837 - '<input type="text" id="breadcrumbs_home_label" name="' . $option_key . '[breadcrumbs][home_label]" value="%s" size="30" />',
1838 - esc_attr($value)
1839 - );
1840 - },
1841 - $page_slug . '_breadcrumbs',
1842 - $SECTION_BREADCRUMBS
1843 - );
1844 -
1845 - add_settings_field(
1846 - 'breadcrumbs_home_url',
1847 - 'Home URL',
1848 - function() use ($option_key) {
1849 - $value = Metasync::get_option('breadcrumbs')['home_url'] ?? '';
1850 - printf(
1851 - '<input type="url" id="breadcrumbs_home_url" name="' . $option_key . '[breadcrumbs][home_url]" value="%s" size="50" />',
1852 - esc_attr($value)
1853 - );
1854 - echo '<p class="description">Leave blank to use the site home URL.</p>';
1855 - },
1856 - $page_slug . '_breadcrumbs',
1857 - $SECTION_BREADCRUMBS
1858 - );
1859 -
1860 - add_settings_field(
1861 - 'breadcrumbs_show_current_page',
1862 - 'Show Current Page',
1863 - function() use ($option_key) {
1864 - $value = Metasync::get_option('breadcrumbs')['show_current_page'] ?? true;
1865 - printf(
1866 - '<input type="checkbox" id="breadcrumbs_show_current_page" name="' . $option_key . '[breadcrumbs][show_current_page]" value="1" %s />',
1867 - checked(1, $value, false)
1868 - );
1869 - echo '<p class="description">Include the current page as the last (non-linked) crumb.</p>';
1870 - },
1871 - $page_slug . '_breadcrumbs',
1872 - $SECTION_BREADCRUMBS
1873 - );
1874 -
1875 - add_settings_field(
1876 - 'breadcrumbs_prefix_text',
1877 - 'Prefix Text',
1878 - function() use ($option_key) {
1879 - $value = Metasync::get_option('breadcrumbs')['prefix_text'] ?? '';
1880 - printf(
1881 - '<input type="text" id="breadcrumbs_prefix_text" name="' . $option_key . '[breadcrumbs][prefix_text]" value="%s" size="30" />',
1882 - esc_attr($value)
1883 - );
1884 - echo '<p class="description">Optional text before the breadcrumb trail (e.g. "You are here:").</p>';
1885 - },
1886 - $page_slug . '_breadcrumbs',
1887 - $SECTION_BREADCRUMBS
1888 - );
1889 -
1890 - add_settings_field(
1891 - 'breadcrumbs_archive_label_format',
1892 - 'Archive Label Format',
1893 - function() use ($option_key) {
1894 - $value = Metasync::get_option('breadcrumbs')['archive_label_format'] ?? '{name}';
1895 - printf(
1896 - '<input type="text" id="breadcrumbs_archive_label_format" name="' . $option_key . '[breadcrumbs][archive_label_format]" value="%s" size="30" />',
1897 - esc_attr($value)
1898 - );
1899 - echo '<p class="description">Format for archive crumb labels. Use <code>{name}</code> as placeholder for the archive name.</p>';
1900 - },
1901 - $page_slug . '_breadcrumbs',
1902 - $SECTION_BREADCRUMBS
1903 - );
1904 -
1905 1974 # Open Graph / Article meta toggles — surface each new tag family as its
1906 1975 # own opt-out checkbox on the main Settings page (Open Graph accordion).
1907 1976 add_settings_field(
1908 1977 'og_image_dimensions',
@@ -1954,8 +2023,160 @@
1954 2023
1955 2024 }
1956 2025
1957 2026 /**
2027 + * Coerce a loosely-typed option value into an array.
2028 + *
2029 + * The value arrives untyped (a crafted or partial submission may send a
2030 + * scalar for an option stored as an array), so the check lives behind an
2031 + * untyped parameter instead of an inline is_array() guard on a value the
2032 + * docblock already narrows.
2033 + *
2034 + * @param mixed $value Raw option value.
2035 + * @return array The value as an array, or an empty array.
2036 + */
2037 + private static function coerce_to_array($value)
2038 + {
2039 + return is_array($value) ? $value : array();
2040 + }
2041 +
2042 + /**
2043 + * Recursively merge sanitized settings over raw and stored layers.
2044 + *
2045 + * Later layers win per-key; associative arrays merge key-by-key so
2046 + * submitted values only replace their own field, while sequential
2047 + * lists (rows of opening hours, phone numbers) replace wholesale
2048 + * so deleted rows stay deleted.
2049 + *
2050 + * @param array $base Stored option layer (lowest precedence).
2051 + * @param array ...$layers Layers to fold in, lowest precedence first
2052 + * (raw input, then sanitized values).
2053 + * @return array The merged option array.
2054 + */
2055 + private static function merge_option_layers(array $base, array ...$layers)
2056 + {
2057 + foreach ($layers as $layer) {
2058 + foreach ($layer as $key => $value) {
2059 + if (is_array($value)
2060 + && isset($base[$key])
2061 + && is_array($base[$key])
2062 + && !self::is_sequential_list($value)
2063 + && !self::clears_a_stored_list($value, $base[$key])
2064 + ) {
2065 + $base[$key] = self::merge_option_layers($base[$key], $value);
2066 + } else {
2067 + $base[$key] = $value;
2068 + }
2069 + }
2070 + }
2071 + return $base;
2072 + }
2073 +
2074 + /**
2075 + * Enforce the read-only contract on general.otto_pixel_uuid.
2076 + *
2077 + * The UUID is owned by trusted Search Atlas flows and explicit disconnect.
2078 + * Any changed value submitted through the settings form is rejected and the
2079 + * stored UUID is preserved verbatim. Programmatic trusted writers bypass
2080 + * this form-submission guard.
2081 + *
2082 + * @param array $merged The layered-merge result about to be saved.
2083 + * @param array $stored The stored option, used as the preserved value.
2084 + * @return array The merged array with the stored otto_pixel_uuid kept.
2085 + */
2086 + private static function protect_otto_pixel_uuid(array $merged, array $stored)
2087 + {
2088 + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2089 + if (!isset($_POST['metasync_options']['general'])
2090 + || !is_array($_POST['metasync_options']['general'])
2091 + || !array_key_exists('otto_pixel_uuid', $_POST['metasync_options']['general'])) {
2092 + return $merged;
2093 + }
2094 +
2095 + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2096 + $submitted = $_POST['metasync_options']['general']['otto_pixel_uuid'];
2097 + if (is_string($submitted)) {
2098 + $submitted = sanitize_text_field(wp_unslash($submitted));
2099 + }
2100 + $candidate = $merged['general']['otto_pixel_uuid'] ?? null;
2101 +
2102 + // A trusted flow may have replaced the submitted value after the form
2103 + // was posted; such a value is not user-authored and must survive.
2104 + // The trust inference must NOT rest on the inequality alone: the
2105 + // merged candidate comes from the raw submission layer and can differ
2106 + // from the sanitized $submitted merely because sanitization alters
2107 + // the payload (tags, line breaks, repeated spaces). A mismatch on
2108 + // its own must never authorize storing the raw value, so only a
2109 + // sanctioned shape may pass through: the empty string (the
2110 + // disconnected state) or a strictly valid UUID — the only values
2111 + // trusted writers ever set. Everything else falls back to stored.
2112 + if ($candidate !== $submitted
2113 + && ($candidate === '' || Metasync::is_valid_uuid($candidate))) {
2114 + return $merged;
2115 + }
2116 +
2117 + $stored_value = $stored['general']['otto_pixel_uuid'] ?? null;
2118 + if ($candidate === $stored_value) {
2119 + return $merged;
2120 + }
2121 +
2122 + if (is_string($stored_value)) {
2123 + $merged['general']['otto_pixel_uuid'] = $stored_value;
2124 + } else {
2125 + unset($merged['general']['otto_pixel_uuid']);
2126 + }
2127 +
2128 + add_settings_error(
2129 + 'metasync_messages',
2130 + 'metasync_otto_pixel_uuid_readonly',
2131 + __('OTTO Pixel UUID is read-only and can only be assigned by the Search Atlas / heartbeat synchronization.', 'metasync'),
2132 + 'error'
2133 + );
2134 + return $merged;
2135 + }
2136 +
2137 + /**
2138 + * Whether the array is a numerically-indexed list (keys 0..n-1).
2139 + *
2140 + * Answers false for the empty array — range(0, -1) counts downwards and
2141 + * yields [0, -1], not []. That is load-bearing rather than incidental: an
2142 + * empty array is ambiguous, and clears_a_stored_list() is where the
2143 + * ambiguity is resolved.
2144 + *
2145 + * @param array $value Array to inspect.
2146 + * @return bool True for lists, false for associative maps.
2147 + */
2148 + private static function is_sequential_list(array $value)
2149 + {
2150 + return array_keys($value) === range(0, count($value) - 1);
2151 + }
2152 +
2153 + /**
2154 + * Whether an empty submitted value is a stored list being cleared.
2155 + *
2156 + * An empty array reaches the merge meaning one of two opposite things, and
2157 + * only the stored value tells them apart.
2158 + *
2159 + * When what is stored is a list — the roles of a multi-select, the rows of
2160 + * a repeater — an empty submission means the user removed every entry, and
2161 + * it has to replace the stored value or the clear is silently ignored.
2162 + *
2163 + * When what is stored is an associative map, an empty submission means the
2164 + * form carried nothing for that section: a tab whose every checkbox is
2165 + * unchecked posts no fields at all. Replacing there would wipe the section,
2166 + * API key included, which is the whole failure this merge exists to
2167 + * prevent. So that case still merges, leaving the stored keys standing.
2168 + *
2169 + * @param array $value The submitted value.
2170 + * @param array $stored The value already in the option.
2171 + * @return bool True when the submission is clearing a list.
2172 + */
2173 + private static function clears_a_stored_list(array $value, array $stored)
2174 + {
2175 + return $value === array() && self::is_sequential_list($stored);
2176 + }
2177 +
2178 + /**
1958 2179 * Sanitize each setting field as needed.
1959 2180 *
1960 2181 * Formerly Metasync_Admin::sanitize().
1961 2182 *
@@ -1963,21 +2184,62 @@
1963 2184 * @return array Sanitized settings
1964 2185 */
1965 2186 public function sanitize($input)
1966 2187 {
1967 - $new_input = Metasync::get_option();
2188 + $submitted_generation = isset($_POST['metasync_pw_reset_generation']) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2189 + ? absint($_POST['metasync_pw_reset_generation']) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2190 + : null;
2191 + // Legacy/stale forms that include a password but no reset generation
2192 + // must never be allowed to overwrite a newer recovery password.
2193 + if (!self::$allow_recovery_password_write
2194 + && $submitted_generation === null
2195 + && isset($input['whitelabel']['settings_password'])) {
2196 + unset($input['whitelabel']['settings_password']);
2197 + add_settings_error(
2198 + 'metasync_messages',
2199 + 'metasync_password_generation_missing',
2200 + __('This settings form is stale. Reload the page before changing the password.', 'metasync'),
2201 + 'error'
2202 + );
2203 + }
2204 + // Every settings save rewrites the aggregate option, including its
2205 + // current password value. Serialize the full save with recovery even
2206 + // when the submitted tab does not expose the password field.
2207 + if (!$this->acquire_settings_recovery_lock()) {
2208 + add_settings_error(
2209 + 'metasync_messages',
2210 + 'metasync_password_update_busy',
2211 + __('Password recovery is being processed. No settings were saved; please try again.', 'metasync'),
2212 + 'error'
2213 + );
2214 + } else {
2215 + self::$options_generation_at_sanitize = $submitted_generation !== null
2216 + ? $submitted_generation
2217 + : (int) get_option('metasync_pw_reset_generation', 0);
2218 + }
2219 + $input = self::coerce_to_array($input);
2220 +
2221 + // The saved option is assembled from three layers (see the merge at
2222 + // the end of this method): the stored option, the raw submission,
2223 + // and the sanitized values collected in $new_input. Sanitization
2224 + // therefore wins over raw input, a submitted field without a
2225 + // sanitizer still beats its stored value, and stored keys the
2226 + // submission omits survive instead of being dropped.
2227 + $stored_option = self::coerce_to_array(Metasync::get_option());
2228 + $new_input = array();
1968 2229 $whitelabel_validation_failed = false;
2230 + $whitelabel_reset = false;
1969 2231
1970 2232 # Determine which tab is being submitted (same logic as AJAX handler)
1971 2233 # Check POST first (from AJAX), then GET (from form action URL)
1972 2234 $active_tab = 'general';
1973 - if (isset($_POST['active_tab'])) {
1974 - $active_tab = sanitize_text_field($_POST['active_tab']);
1975 - } elseif (isset($_GET['tab'])) {
1976 - $active_tab = sanitize_text_field($_GET['tab']);
1977 - } elseif (isset($_POST['_wp_http_referer'])) {
2235 + if (isset($_POST['active_tab'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2236 + $active_tab = sanitize_text_field(wp_unslash($_POST['active_tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2237 + } elseif (isset($_GET['tab'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- sanitize callback, the caller already verified the nonce
2238 + $active_tab = sanitize_text_field(wp_unslash($_GET['tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- sanitize callback, the caller already verified the nonce
2239 + } elseif (isset($_POST['_wp_http_referer'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
1978 2240 # Parse referer URL to get tab parameter
1979 - $referer = wp_parse_url($_POST['_wp_http_referer']);
2241 + $referer = wp_parse_url(esc_url_raw(wp_unslash($_POST['_wp_http_referer']))); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
1980 2242 if (isset($referer['query'])) {
1981 2243 parse_str($referer['query'], $referer_params);
1982 2244 if (isset($referer_params['tab'])) {
1983 2245 $active_tab = sanitize_text_field($referer_params['tab']);
@@ -1984,8 +2246,9 @@
1984 2246 }
1985 2247 }
1986 2248 }
1987 2249 $general_tab_submitted = ($active_tab === 'general');
2250 + $compatibility_tab_submitted = ($active_tab === 'compatibility');
1988 2251
1989 2252 // General Settings
1990 2253 if (isset($input['general']['apikey'])) {
1991 2254 $new_input['general']['apikey'] = sanitize_text_field($input['general']['apikey']);
@@ -1990,8 +2253,26 @@
1990 2253 if (isset($input['general']['apikey'])) {
1991 2254 $new_input['general']['apikey'] = sanitize_text_field($input['general']['apikey']);
1992 2255 }
1993 2256
2257 + # Plugin-compatibility toggles live on the Compatibility page, which
2258 + # saves through options.php with a hidden active_tab=compatibility so
2259 + # the General-tab-gated blocks below never fire. Sanitize the two
2260 + # toggles explicitly so the raw submission never reaches storage
2261 + # unsanitized, and so a value that is absent (toggle hidden because
2262 + # its plugin is not active) keeps its stored value via the merge.
2263 + if ($compatibility_tab_submitted) {
2264 + if (isset($input['general']['otto_wp_rocket_compat'])) {
2265 + $new_input['general']['otto_wp_rocket_compat'] = sanitize_text_field($input['general']['otto_wp_rocket_compat']);
2266 + }
2267 + if (isset($input['general']['otto_sg_optimizer_compat'])) {
2268 + $new_input['general']['otto_sg_optimizer_compat'] = sanitize_text_field($input['general']['otto_sg_optimizer_compat']);
2269 + }
2270 + if (isset($input['general']['bookingpress_session_compat'])) {
2271 + $new_input['general']['bookingpress_session_compat'] = sanitize_text_field($input['general']['bookingpress_session_compat']);
2272 + }
2273 + }
2274 +
1994 2275 // Meta Box Visibility Settings
1995 2276 if (isset($input['general']) && $general_tab_submitted) {
1996 2277 $checkbox_fields = [
1997 2278 'disable_common_robots_metabox',
@@ -1999,10 +2280,15 @@
1999 2280 'disable_redirection_metabox',
2000 2281 'disable_canonical_metabox',
2001 2282 'disable_social_opengraph_metabox',
2002 2283 'disable_schema_markup_metabox',
2284 + 'disable_language_alternates_metabox',
2003 2285 'disable_seo_metabox',
2004 - 'open_external_links'
2286 + 'open_external_links',
2287 + // Consent switch for writing into other SEO plugins. Absent from
2288 + // the POST means unchecked, which must resolve to off — a
2289 + // permanent write into another plugin's data is never inferred.
2290 + Metasync_Seo_Backup::CONSENT_OPTION_KEY
2005 2291 ];
2006 2292
2007 2293 foreach ($checkbox_fields as $field) {
2008 2294 if (isset($input['general'][$field])) {
@@ -2007,15 +2293,27 @@
2007 2293 foreach ($checkbox_fields as $field) {
2008 2294 if (isset($input['general'][$field])) {
2009 2295 $new_input['general'][$field] = filter_var($input['general'][$field], FILTER_VALIDATE_BOOLEAN);
2010 2296 } else {
2297 + // Unchecked HTML checkboxes are absent from the submission;
2298 + // persist an explicit false so the stored value cannot leak
2299 + // back through the layered merge.
2011 2300 $new_input['general'][$field] = false;
2012 2301 }
2013 - // The final array_merge($new_input, $input) is shallow, so
2014 - // $input['general'] replaces $new_input['general'] wholesale.
2015 - // Write the sanitized boolean back into $input so it survives.
2016 - $input['general'][$field] = $new_input['general'][$field];
2017 2302 }
2303 +
2304 + // SEO priority is a checkbox whose stored value is a string, so it
2305 + // cannot ride the boolean loop above. Only the exact opt-in value
2306 + // is accepted; unchecked (absent) or anything else resolves to
2307 + // custom-first. Writing an explicit value rather than leaving the
2308 + // key absent matters for the same reason the loop above writes
2309 + // false: the layered merge would otherwise let the stored 'otto'
2310 + // leak back and the box could never be unticked.
2311 + $new_input['general']['seo_priority'] =
2312 + (isset($input['general']['seo_priority'])
2313 + && $input['general']['seo_priority'] === Metasync_Seo_Precedence::PRIORITY_OTTO)
2314 + ? Metasync_Seo_Precedence::PRIORITY_OTTO
2315 + : 'custom';
2018 2316 }
2019 2317
2020 2318 // Site Verification Settings
2021 2319 if (isset($input['searchengines']['bing_site_verification'])) {
@@ -2117,13 +2415,28 @@
2117 2415 }
2118 2416
2119 2417 // Breadcrumbs Settings
2120 2418 if (isset($input['breadcrumbs'])) {
2121 - $new_input['breadcrumbs']['enabled'] = !empty($input['breadcrumbs']['enabled']);
2122 - $new_input['breadcrumbs']['show_current_page'] = !empty($input['breadcrumbs']['show_current_page']);
2123 - $new_input['breadcrumbs']['disable_schema'] = !empty($input['breadcrumbs']['disable_schema']);
2419 + // Checkbox fields are only written when the submitting form
2420 + // actually rendered them. render_breadcrumbs_section() pairs every
2421 + // checkbox with a hidden companion input, so the key is always
2422 + // present there and unchecking works. A missing key means the form
2423 + // does not manage that field, so the stored value is preserved
2424 + // instead of being silently reset to false.
2425 + foreach (array('enabled', 'show_current_page', 'disable_schema') as $breadcrumb_flag) {
2426 + if (isset($input['breadcrumbs'][$breadcrumb_flag])) {
2427 + $new_input['breadcrumbs'][$breadcrumb_flag] = !empty($input['breadcrumbs'][$breadcrumb_flag]);
2428 + }
2429 + }
2430 + // The Custom radio always renders and can submit an empty value
2431 + // when selected without typing. An empty separator would render
2432 + // a blank gap between breadcrumb links, so it is dropped and the
2433 + // stored value survives, exactly like an absent key.
2124 2434 if (isset($input['breadcrumbs']['separator'])) {
2125 - $new_input['breadcrumbs']['separator'] = sanitize_text_field($input['breadcrumbs']['separator']);
2435 + $breadcrumb_separator = sanitize_text_field($input['breadcrumbs']['separator']);
2436 + if ('' !== $breadcrumb_separator) {
2437 + $new_input['breadcrumbs']['separator'] = $breadcrumb_separator;
2438 + }
2126 2439 }
2127 2440 if (isset($input['breadcrumbs']['home_label'])) {
2128 2441 $new_input['breadcrumbs']['home_label'] = sanitize_text_field($input['breadcrumbs']['home_label']);
2129 2442 }
@@ -2135,19 +2448,25 @@
2135 2448 }
2136 2449 if (isset($input['breadcrumbs']['archive_label_format'])) {
2137 2450 $new_input['breadcrumbs']['archive_label_format'] = sanitize_text_field($input['breadcrumbs']['archive_label_format']);
2138 2451 }
2139 - // Remove raw breadcrumbs so sanitized values (especially checkbox
2140 - // booleans) survive the shallow array_merge($new_input, $input).
2452 + // Drop the raw section: the sanitized copies above (including the
2453 + // explicit checkbox booleans) are authoritative for the merge.
2141 2454 unset($input['breadcrumbs']);
2142 2455 }
2143 2456
2144 - // Code Snippets Settings
2457 + // Code Snippets Settings. These fields intentionally hold raw markup
2458 + // (analytics scripts, verification tags, inline styles) that is echoed
2459 + // verbatim on the front end, so only the request slashes are stripped —
2460 + // tag-stripping sanitizers would destroy the snippet. The form is
2461 + // restricted to users with plugin settings access.
2145 2462 if (isset($input['codesnippets']['header_snippet'])) {
2146 - $new_input['codesnippets']['header_snippet'] = sanitize_text_field($input['codesnippets']['header_snippet']);
2463 + $snippet = wp_unslash($input['codesnippets']['header_snippet']);
2464 + $new_input['codesnippets']['header_snippet'] = is_string($snippet) ? trim($snippet) : '';
2147 2465 }
2148 2466 if (isset($input['codesnippets']['footer_snippet'])) {
2149 - $new_input['codesnippets']['footer_snippet'] = sanitize_text_field($input['codesnippets']['footer_snippet']);
2467 + $snippet = wp_unslash($input['codesnippets']['footer_snippet']);
2468 + $new_input['codesnippets']['footer_snippet'] = is_string($snippet) ? trim($snippet) : '';
2150 2469 }
2151 2470
2152 2471 // Common Setting - Global Settings
2153 2472 $common_robots_field = isset($input['common_robots_meta']) ? 'common_robots_meta' : 'common_robots_mata';
@@ -2224,10 +2543,10 @@
2224 2543 ];
2225 2544 foreach ($og_toggle_fields as $field) {
2226 2545 $new_input['common_meta_settings'][$field] = (isset($input['common_meta_settings'][$field]) && $input['common_meta_settings'][$field] === 'true') ? 'true' : 'false';
2227 2546 }
2228 - // Remove raw common_meta_settings so sanitized toggle values
2229 - // survive the shallow array_merge($new_input, $input).
2547 + // Drop the raw section so only the sanitized toggle values reach
2548 + // the stored option.
2230 2549 unset($input['common_meta_settings']);
2231 2550 }
2232 2551
2233 2552 # Handle whitelabel URL fields with improved empty value handling
@@ -2307,10 +2626,31 @@
2307 2626 }
2308 2627 }
2309 2628
2310 2629 if (isset($input['whitelabel']['settings_password'])) {
2311 - $password_value = trim($input['whitelabel']['settings_password']);
2312 - $new_input['whitelabel']['settings_password'] = Metasync::encrypt_secret(sanitize_text_field($password_value));
2630 + // WordPress unslashes registered settings before invoking the
2631 + // sanitize callback. Preserve the resulting password exactly.
2632 + $password_value = (string) $input['whitelabel']['settings_password'];
2633 + $encrypted_password = Metasync::encrypt_secret($password_value);
2634 + if ($password_value === '') {
2635 + $new_input['whitelabel']['settings_password'] = '';
2636 + } elseif (Metasync::is_encrypted_secret($password_value)) {
2637 + // update_option() may invoke this sanitizer after the
2638 + // AJAX handler has already encrypted the value. Treat
2639 + // valid ciphertext as an idempotent input, not a failed
2640 + // encryption attempt.
2641 + $new_input['whitelabel']['settings_password'] = $password_value;
2642 + } elseif (Metasync::is_encrypted_secret($encrypted_password) && $encrypted_password !== $password_value) {
2643 + $new_input['whitelabel']['settings_password'] = $encrypted_password;
2644 + } else {
2645 + $new_input['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'] ?? '';
2646 + add_settings_error(
2647 + 'metasync_messages',
2648 + 'metasync_password_encryption_failed',
2649 + __('Unable to securely save the password. Please choose a different password.', 'metasync'),
2650 + 'error'
2651 + );
2652 + }
2313 2653 } else {
2314 2654 if (isset($existing_whitelabel['settings_password'])) {
2315 2655 $new_input['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'];
2316 2656 }
@@ -2415,12 +2755,15 @@
2415 2755 $existing_whitelabel = Metasync::get_option()['whitelabel'] ?? [];
2416 2756 $has_existing_whitelabel = !empty($existing_whitelabel['domain']) || !empty($existing_whitelabel['logo']);
2417 2757
2418 2758 if ($has_existing_whitelabel) {
2759 + // A reset replaces the whole section (password ciphertext,
2760 + // palette, quick links and all) rather than merging leaves.
2761 + $whitelabel_reset = true;
2419 2762 $new_input['whitelabel'] = [
2420 2763 'is_whitelabel' => false,
2421 2764 'domain' => '',
2422 - 'logo' => '',
2765 + 'logo' => '',
2423 2766 'company_name' => '',
2424 2767 'updated_at' => time()
2425 2768 ];
2426 2769
@@ -2430,21 +2773,16 @@
2430 2773 }
2431 2774 }
2432 2775 }
2433 2776
2434 - // Indexation Control Settings
2435 - if (isset($input['seo_controls']['index_date_archives'])) {
2436 - $new_input['seo_controls']['index_date_archives'] = boolval($input['seo_controls']['index_date_archives']);
2777 + // Indexation Control Settings. Stored as the strings 'true'/'false' (the form,
2778 + // the setup wizard and count_index_rule_changes() all compare against those);
2779 + // boolval('false') is true, which silently turned every unchecked box on.
2780 + foreach (array('index_date_archives', 'index_tag_archives', 'index_author_archives', 'index_format_archives') as $index_field) {
2781 + if (isset($input['seo_controls'][$index_field])) {
2782 + $new_input['seo_controls'][$index_field] = filter_var($input['seo_controls'][$index_field], FILTER_VALIDATE_BOOLEAN) ? 'true' : 'false';
2783 + }
2437 2784 }
2438 - if (isset($input['seo_controls']['index_tag_archives'])) {
2439 - $new_input['seo_controls']['index_tag_archives'] = boolval($input['seo_controls']['index_tag_archives']);
2440 - }
2441 - if (isset($input['seo_controls']['index_author_archives'])) {
2442 - $new_input['seo_controls']['index_author_archives'] = boolval($input['seo_controls']['index_author_archives']);
2443 - }
2444 - if (isset($input['seo_controls']['index_format_archives'])) {
2445 - $new_input['seo_controls']['index_format_archives'] = boolval($input['seo_controls']['index_format_archives']);
2446 - }
2447 2785
2448 2786 // Handle post-save heartbeat trigger for domain changes
2449 2787 if (isset($new_input['_trigger_heartbeat_after_save'])) {
2450 2788 $context = $new_input['_trigger_heartbeat_after_save'];
@@ -2455,10 +2793,100 @@
2455 2793 do_action('metasync_trigger_immediate_heartbeat', 'Whitelabel domain change - ' . $context);
2456 2794 });
2457 2795 }
2458 2796
2459 - $result = array_merge($new_input, $input);
2797 + // Headless settings are never written through this form. They are owned by
2798 + // Metasync_Headless_Config and saved by their own handler, so anything
2799 + // arriving here under that key is a stale form or a hand-built request.
2800 + // The merge below would let it write over the stored block key by key —
2801 + // switching the mode off that way removes the GraphQL field and breaks
2802 + // every persisted query a headless frontend holds, so the key is dropped
2803 + // rather than merged.
2804 + //
2805 + // Except when the owner is the one writing. register_setting() makes this
2806 + // method a `sanitize_option_metasync_options` filter, and WordPress runs
2807 + // it on every update_option() call for the option, not only on Settings
2808 + // API form posts — so dropping the key unconditionally also dropped
2809 + // Metasync_Headless_Config's own writes, and the Headless Mode block
2810 + // could never save anything on a real request.
2811 + if (!Metasync_Headless_Config::is_writing()) {
2812 + unset($input[Metasync_Headless_Config::SETTINGS_KEY]);
2813 + }
2460 2814
2815 + // Keep the fully sanitized/encrypted white-label values. The layered
2816 + // merge below already lets them win; mirroring them into the raw
2817 + // layer also excludes stray raw whitelabel keys from the saved option.
2818 + if (isset($input['whitelabel'], $new_input['whitelabel'])) {
2819 + $input['whitelabel'] = $new_input['whitelabel'];
2820 + }
2821 +
2822 + // The branding contract must also hold on the native options.php
2823 + // submit (administrators with JavaScript disabled or broken — the
2824 + // AJAX handler never runs for them): a value that could not be
2825 + // written to metasync.php must not be persisted either. A rejected
2826 + // key is stripped from the raw layer so the layered merge keeps the
2827 + // stored value, leaving options and the physical header untouched.
2828 + // The AJAX save and the JSON import already enforce this contract
2829 + // before their writes, so for them this is a consistent no-op.
2830 + if (!class_exists('Metasync_Activator')) {
2831 + // Defensive only: real requests always have the activator via the
2832 + // plugin boot / Composer classmap. Plain dirname() (not
2833 + // plugin_dir_path()) keeps this loadable in the isolated unit
2834 + // harnesses that define neither.
2835 + $activator_file = dirname(__FILE__) . '/class-metasync-activator.php';
2836 + if (is_readable($activator_file)) {
2837 + require_once $activator_file;
2838 + }
2839 + }
2840 + if (class_exists('Metasync_Activator') && isset($input['general']) && is_array($input['general'])) {
2841 + $branding_contract_fields = array(
2842 + 'white_label_plugin_name' => false,
2843 + 'white_label_plugin_description' => false,
2844 + 'white_label_plugin_author' => false,
2845 + 'white_label_plugin_author_uri' => true,
2846 + 'white_label_plugin_uri' => true,
2847 + );
2848 + foreach ($branding_contract_fields as $branding_key => $branding_is_url) {
2849 + if (!array_key_exists($branding_key, $input['general']) || !is_string($input['general'][$branding_key])) {
2850 + continue;
2851 + }
2852 + $branding_value = trim(wp_unslash($input['general'][$branding_key]));
2853 + if ($branding_value === ''
2854 + || Metasync_Activator::is_valid_plugin_header_value($branding_value, $branding_is_url)) {
2855 + continue;
2856 + }
2857 + add_settings_error(
2858 + 'metasync_messages',
2859 + 'metasync_branding_' . $branding_key,
2860 + $branding_is_url
2861 + ? sprintf('%s must be a valid http:// or https:// address.', $branding_key === 'white_label_plugin_author_uri' ? 'Author URL' : 'Plugin URL')
2862 + : sprintf('%s contains characters that cannot be saved (line breaks, control or invisible characters, "*/", or PHP tags are not allowed).', $branding_key === 'white_label_plugin_name' ? 'Plugin Name' : ($branding_key === 'white_label_plugin_description' ? 'Plugin Description' : 'Author')),
2863 + 'error'
2864 + );
2865 + unset($input['general'][$branding_key]);
2866 + }
2867 + }
2868 +
2869 + // Layered merge: stored option, then the raw submission, then the
2870 + // sanitized values. Replaces the former shallow array_merge(), which
2871 + // let the raw submission overwrite every sanitized section wholesale.
2872 + $result = self::merge_option_layers($stored_option, $input, $new_input);
2873 +
2874 + // otto_pixel_uuid is read-only. The field is populated only by
2875 + // the trusted Search Atlas / heartbeat flows, and sanitize_option runs
2876 + // on every update_option() of this option — including the AJAX save and
2877 + // a forged POST that smuggles the key in — so this is the last line of
2878 + // defence for the shape of the stored value. An empty string (the
2879 + // disconnected state) and a valid UUID pass through; anything else
2880 + // (a username or other arbitrary text) falls back to the stored value.
2881 + // The disconnect flow is unaffected: it unsets the key and writes
2882 + // through write_options_without_stored_merge(), never through here.
2883 + $result = self::protect_otto_pixel_uuid($result, $stored_option);
2884 +
2885 + if ($whitelabel_reset) {
2886 + $result['whitelabel'] = $new_input['whitelabel'];
2887 + }
2888 +
2461 2889 if ($whitelabel_validation_failed) {
2462 2890 $result['whitelabel'] = Metasync::get_option()['whitelabel'] ?? [];
2463 2891 }
2464 2892
@@ -2473,9 +2901,10 @@
2473 2901 * Moved from Metasync_Admin – the admin class now delegates here.
2474 2902 */
2475 2903 public function meta_sync_save_settings()
2476 2904 {
2477 - if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce($_POST['meta_sync_nonce'], 'meta_sync_general_setting_nonce')) {
2905 + $meta_sync_nonce = isset($_POST['meta_sync_nonce']) ? sanitize_text_field(wp_unslash($_POST['meta_sync_nonce'])) : '';
2906 + if (!wp_verify_nonce($meta_sync_nonce, 'meta_sync_general_setting_nonce')) {
2478 2907 wp_send_json_error(array('message' => 'Invalid nonce'));
2479 2908 return;
2480 2909 }
2481 2910
@@ -2483,8 +2912,31 @@
2483 2912 wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
2484 2913 return;
2485 2914 }
2486 2915
2916 + self::$defer_settings_recovery_lock_release = true;
2917 + if (!$this->acquire_settings_recovery_lock()) {
2918 + self::$defer_settings_recovery_lock_release = false;
2919 + self::$abort_locked_settings_update = false;
2920 + return wp_send_json_error(array('message' => 'Password recovery is being processed. No settings were saved; please try again.'), 409);
2921 + }
2922 +
2923 + $current_generation = (int) get_option('metasync_pw_reset_generation', 0);
2924 + $password_submitted = isset($_POST['metasync_options']['whitelabel']['settings_password']);
2925 + if ($password_submitted && !isset($_POST['metasync_pw_reset_generation'])) {
2926 + $this->force_release_settings_recovery_lock();
2927 + return wp_send_json_error(array('message' => 'A settings password update requires a current page generation. Reload the page and try again.'), 409);
2928 + }
2929 + if (isset($_POST['metasync_pw_reset_generation'])) {
2930 + self::$options_generation_at_sanitize = absint($_POST['metasync_pw_reset_generation']);
2931 + if ((int) self::$options_generation_at_sanitize !== $current_generation) {
2932 + $this->force_release_settings_recovery_lock();
2933 + return wp_send_json_error(array('message' => 'The settings password changed while this page was open. Reload the page and try again.'), 409);
2934 + }
2935 + } else {
2936 + self::$options_generation_at_sanitize = $current_generation;
2937 + }
2938 +
2487 2939 $text_fields = [
2488 2940 'searchatlas_api_key', 'apikey',
2489 2941 'white_label_plugin_name', 'white_label_plugin_description',
2490 2942 'white_label_plugin_author', 'white_label_plugin_menu_slug',
@@ -2489,9 +2941,12 @@
2489 2941 'white_label_plugin_name', 'white_label_plugin_description',
2490 2942 'white_label_plugin_author', 'white_label_plugin_menu_slug',
2491 2943 'white_label_plugin_menu_icon', 'enabled_plugin_css',
2492 2944 'enabled_elementor_plugin_css_color','enabled_elementor_plugin_css',
2493 - 'otto_pixel_uuid','periodic_clear_otto_cache','periodic_clear_ottopage_cache',
2945 + // The OTTO Pixel UUID is deliberately NOT listed here. It is
2946 + // read-only and populated only by the trusted Search Atlas /
2947 + // heartbeat flows; sanitize() preserves the stored value.
2948 + 'periodic_clear_otto_cache','periodic_clear_ottopage_cache',
2494 2949 'periodic_clear_ottopost_cache', 'whitelabel_otto_name', 'otto_wp_rocket_compat',
2495 2950 'otto_sg_optimizer_compat',
2496 2951 'bookingpress_session_compat'
2497 2952 ];
@@ -2499,9 +2954,9 @@
2499 2954 $url_fields = [
2500 2955 'white_label_plugin_author_uri', 'white_label_plugin_uri'
2501 2956 ];
2502 2957
2503 - $bool_fields = ['otto_disable_on_loggedin', 'otto_disable_preview_button', 'otto_disable_for_bots' , 'hide_dashboard_framework', 'show_admin_bar_status', 'enable_auto_updates', 'disable_common_robots_metabox', 'disable_advance_robots_metabox', 'disable_redirection_metabox', 'disable_canonical_metabox', 'disable_social_opengraph_metabox', 'disable_schema_markup_metabox', 'disable_seo_metabox', 'open_external_links'];
2958 + $bool_fields = ['otto_disable_on_loggedin', 'otto_disable_preview_button', 'otto_disable_for_bots' , 'hide_dashboard_framework', 'show_admin_bar_status','disable_common_robots_metabox', 'disable_advance_robots_metabox', 'disable_redirection_metabox', 'disable_canonical_metabox', 'disable_social_opengraph_metabox', 'disable_schema_markup_metabox', 'disable_language_alternates_metabox', 'disable_seo_metabox', 'open_external_links', Metasync_Seo_Backup::CONSENT_OPTION_KEY];
2504 2959
2505 2960 $url_fields = ['white_label_plugin_author_uri', 'white_label_plugin_uri'];
2506 2961
2507 2962 $metasync_options = Metasync::get_option();
@@ -2513,9 +2968,9 @@
2513 2968 }
2514 2969
2515 2970 $validation_errors = [];
2516 2971
2517 - $active_tab = isset($_POST['active_tab']) ? sanitize_text_field($_POST['active_tab']) : 'general';
2972 + $active_tab = isset($_POST['active_tab']) ? sanitize_text_field(wp_unslash($_POST['active_tab'])) : 'general';
2518 2973 $general_tab_submitted = ($active_tab === 'general');
2519 2974 $whitelabel_tab_submitted = ($active_tab === 'whitelabel');
2520 2975
2521 2976 // Whitelabel field validation issues are collected per-field and reported back
@@ -2521,13 +2976,27 @@
2521 2976 // Whitelabel field validation issues are collected per-field and reported back
2522 2977 // without blocking the rest of the settings save.
2523 2978 $whitelabel_errors = [];
2524 2979
2980 + // Branding field errors use the same contract as the physical
2981 + // plugin-header updater; they are blocking because a value that cannot
2982 + // be written to metasync.php must not be persisted to the options
2983 + // either (that divergence is exactly what WP-636 closes).
2984 + $branding_errors = [];
2985 +
2986 + // The five white-label branding fields share the plugin-header
2987 + // validation contract, so the activator (which owns that contract)
2988 + // must be available before the field loops run.
2989 + if (!class_exists('Metasync_Activator')) {
2990 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-activator.php';
2991 + }
2992 +
2525 2993 foreach ($text_fields as $field) {
2526 2994
2527 2995 if (isset($_POST['metasync_options']['general'][$field])) {
2528 2996
2529 - $value = trim($_POST['metasync_options']['general'][$field]);
2997 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every storing branch below sanitises via sanitize_text_field/sanitize_title/esc_url_raw before the value reaches the option.
2998 + $value = trim(wp_unslash($_POST['metasync_options']['general'][$field]));
2530 2999
2531 3000 // searchatlas_api_key must be clearable to support key removal
2532 3001 $clearable_fields = [
2533 3002 'searchatlas_api_key',
@@ -2535,10 +3004,9 @@
2535 3004 'white_label_plugin_description',
2536 3005 'white_label_plugin_author',
2537 3006 'white_label_plugin_menu_slug',
2538 3007 'white_label_plugin_menu_icon',
2539 - 'whitelabel_otto_name',
2540 - 'otto_pixel_uuid'
3008 + 'whitelabel_otto_name'
2541 3009 ];
2542 3010
2543 3011 if ($value === '' && !in_array($field, $clearable_fields)) {
2544 3012 continue;
@@ -2553,8 +3021,25 @@
2553 3021 continue;
2554 3022 }
2555 3023 }
2556 3024
3025 + // Branding text must satisfy the plugin-header contract. The
3026 + // raw value is checked before sanitisation so multiline or
3027 + // control-character input is rejected with an error instead of
3028 + // being silently mangled into something else that gets saved.
3029 + $branding_text_fields = [
3030 + 'white_label_plugin_name' => 'Plugin Name',
3031 + 'white_label_plugin_description' => 'Plugin Description',
3032 + 'white_label_plugin_author' => 'Author',
3033 + ];
3034 + if (isset($branding_text_fields[$field])
3035 + && $value !== ''
3036 + && !Metasync_Activator::is_valid_plugin_header_value($value, false)) {
3037 + $branding_errors[$field] = $branding_text_fields[$field]
3038 + . ' contains characters that cannot be saved (line breaks, control or invisible characters, "*/", or PHP tags are not allowed).';
3039 + continue;
3040 + }
3041 +
2557 3042 if ($field === 'white_label_plugin_menu_icon') {
2558 3043
2559 3044 if ($value === '') {
2560 3045 $metasync_options['general'][$field] = '';
@@ -2564,9 +3049,9 @@
2564 3049 } elseif (filter_var($value, FILTER_VALIDATE_URL)) {
2565 3050
2566 3051 $image_extensions = ['png', 'svg'];
2567 3052
2568 - $path = parse_url($value, PHP_URL_PATH);
3053 + $path = wp_parse_url($value, PHP_URL_PATH);
2569 3054 $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
2570 3055
2571 3056 if (in_array($extension, $image_extensions, true)) {
2572 3057 $metasync_options['general'][$field] = esc_url_raw($value);
@@ -2580,9 +3065,9 @@
2580 3065 // Self-correcting slug: a URL-shaped value is reduced to a valid WP
2581 3066 // menu slug so it can never break sidebar link generation.
2582 3067 $metasync_options['general'][$field] = sanitize_title($value);
2583 3068 } else {
2584 - $metasync_options['general'][$field] = sanitize_text_field($_POST['metasync_options']['general'][$field]);
3069 + $metasync_options['general'][$field] = sanitize_text_field(wp_unslash($_POST['metasync_options']['general'][$field]));
2585 3070 }
2586 3071 }
2587 3072 }
2588 3073
@@ -2589,9 +3074,9 @@
2589 3074 $textarea_fields = ['otto_bot_whitelist', 'otto_bot_blacklist'];
2590 3075 if ($general_tab_submitted) {
2591 3076 foreach ($textarea_fields as $field) {
2592 3077 if (isset($_POST['metasync_options']['general'][$field])) {
2593 - $metasync_options['general'][$field] = sanitize_textarea_field($_POST['metasync_options']['general'][$field]);
3078 + $metasync_options['general'][$field] = sanitize_textarea_field(wp_unslash($_POST['metasync_options']['general'][$field]));
2594 3079 } else {
2595 3080 $metasync_options['general'][$field] = '';
2596 3081 }
2597 3082 }
@@ -2599,13 +3084,23 @@
2599 3084
2600 3085 if ($general_tab_submitted) {
2601 3086 foreach ($bool_fields as $field) {
2602 3087 if (isset($_POST['metasync_options']['general'][$field])) {
2603 - $metasync_options['general'][$field] = filter_var($_POST['metasync_options']['general'][$field], FILTER_VALIDATE_BOOLEAN);
3088 + $metasync_options['general'][$field] = filter_var(wp_unslash($_POST['metasync_options']['general'][$field]), FILTER_VALIDATE_BOOLEAN);
2604 3089 }else {
2605 3090 $metasync_options['general'][$field] = false;
2606 3091 }
2607 3092 }
3093 +
3094 + // SEO priority is a checkbox that stores a string rather than a
3095 + // bool, so it cannot ride the loop above. Only the exact opt-in
3096 + // value is stored; an unchecked box (or any tampered payload)
3097 + // falls back to custom-first, which is the pre-existing behaviour.
3098 + $metasync_options['general']['seo_priority'] =
3099 + (isset($_POST['metasync_options']['general']['seo_priority'])
3100 + && $_POST['metasync_options']['general']['seo_priority'] === Metasync_Seo_Precedence::PRIORITY_OTTO)
3101 + ? Metasync_Seo_Precedence::PRIORITY_OTTO
3102 + : 'custom';
2608 3103 }
2609 3104
2610 3105 if ($general_tab_submitted || $whitelabel_tab_submitted) {
2611 3106 foreach ($url_fields as $field) {
@@ -2610,9 +3105,10 @@
2610 3105 if ($general_tab_submitted || $whitelabel_tab_submitted) {
2611 3106 foreach ($url_fields as $field) {
2612 3107 if (isset($_POST['metasync_options']['general'][$field])) {
2613 3108
2614 - $value = trim($_POST['metasync_options']['general'][$field]);
3109 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every storing branch below sanitises via sanitize_text_field/sanitize_title/esc_url_raw before the value reaches the option.
3110 + $value = trim(wp_unslash($_POST['metasync_options']['general'][$field]));
2615 3111
2616 3112 $whitelabel_clearable_url_fields = [
2617 3113 'white_label_plugin_author_uri',
2618 3114 'white_label_plugin_uri'
@@ -2628,20 +3124,38 @@
2628 3124 }
2629 3125
2630 3126 if (filter_var($value, FILTER_VALIDATE_URL)) {
2631 3127
2632 - $parsed_url = parse_url($value);
3128 + $parsed_url = wp_parse_url($value);
2633 3129 $host = isset($parsed_url['host']) ? $parsed_url['host'] : '';
2634 3130
2635 3131 if (strpos($host, '.') !== false || filter_var($host, FILTER_VALIDATE_IP)) {
2636 - $metasync_options['general'][$field] = esc_url_raw($value);
3132 + // Branding URLs must additionally satisfy the plugin-header
3133 + // contract (http/https scheme, no embedded control characters);
3134 + // anything else would be saved but silently fail to reach
3135 + // metasync.php, leaving options inconsistent with the Plugins
3136 + // screen branding.
3137 + $branding_url_field_names = [
3138 + 'white_label_plugin_author_uri' => 'Author URL',
3139 + 'white_label_plugin_uri' => 'Plugin URL',
3140 + ];
3141 + if (!Metasync_Activator::is_valid_plugin_header_value($value, true)) {
3142 + $branding_errors[$field] = $branding_url_field_names[$field]
3143 + . ' must be a valid http:// or https:// address.';
3144 + } else {
3145 + $metasync_options['general'][$field] = esc_url_raw($value);
3146 + }
2637 3147 } else {
2638 3148 $field_name = ($field === 'white_label_plugin_author_uri') ? 'Author URL' : 'Plugin URL';
2639 - $validation_errors[] = 'Invalid ' . $field_name . ' format. Please use a proper domain name (e.g., example.com).';
3149 + // Pushed into $branding_errors only: the blocking
3150 + // merge below appends every branding message to the
3151 + // response errors, so a second direct push would render
3152 + // the same message twice.
3153 + $branding_errors[$field] = 'Invalid ' . $field_name . ' format. Please use a proper domain name (e.g., example.com).';
2640 3154 }
2641 3155 } else {
2642 3156 $field_name = ($field === 'white_label_plugin_author_uri') ? 'Author URL' : 'Plugin URL';
2643 - $validation_errors[] = 'Invalid ' . $field_name . ' format.';
3157 + $branding_errors[$field] = $field_name . ' must be a valid http:// or https:// address.';
2644 3158 }
2645 3159 }
2646 3160 }
2647 3161 }
@@ -2647,31 +3161,54 @@
2647 3161 }
2648 3162
2649 3163 if ($general_tab_submitted) {
2650 3164 if (isset($_POST['metasync_options']['general']['content_genius_sync_roles']) && is_array($_POST['metasync_options']['general']['content_genius_sync_roles'])) {
2651 - $metasync_options['general']['content_genius_sync_roles'] = array_map('sanitize_text_field', $_POST['metasync_options']['general']['content_genius_sync_roles']);
3165 + $metasync_options['general']['content_genius_sync_roles'] = array_map('sanitize_text_field', wp_unslash($_POST['metasync_options']['general']['content_genius_sync_roles']));
2652 3166 } else {
2653 3167 $metasync_options['general']['content_genius_sync_roles'] = array();
2654 3168 }
2655 3169 }
2656 3170
2657 - if ($general_tab_submitted) {
2658 - $breadcrumbs_input = isset($_POST['metasync_options']['breadcrumbs']) && is_array($_POST['metasync_options']['breadcrumbs'])
2659 - ? $_POST['metasync_options']['breadcrumbs']
2660 - : array();
3171 + // Breadcrumb settings live on their own admin page, which saves through
3172 + // options.php -> sanitize() rather than this AJAX handler. Only touch
3173 + // them here if the submitted form actually carried breadcrumb fields,
3174 + // otherwise a General-tab save would reset every breadcrumb option.
3175 + if ($general_tab_submitted
3176 + && isset($_POST['metasync_options']['breadcrumbs'])
3177 + && is_array($_POST['metasync_options']['breadcrumbs'])
3178 + ) {
3179 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each key is sanitised below (bool cast for flags, sanitize_text_field for the separator).
3180 + $breadcrumbs_input = wp_unslash($_POST['metasync_options']['breadcrumbs']);
2661 3181
2662 3182 if (!isset($metasync_options['breadcrumbs']) || !is_array($metasync_options['breadcrumbs'])) {
2663 3183 $metasync_options['breadcrumbs'] = array();
2664 3184 }
2665 3185
2666 - $metasync_options['breadcrumbs']['enabled'] = !empty($breadcrumbs_input['enabled']);
2667 - $metasync_options['breadcrumbs']['disable_schema'] = !empty($breadcrumbs_input['disable_schema']);
2668 - $metasync_options['breadcrumbs']['show_current_page'] = !empty($breadcrumbs_input['show_current_page']);
2669 - $metasync_options['breadcrumbs']['separator'] = isset($breadcrumbs_input['separator']) ? sanitize_text_field($breadcrumbs_input['separator']) : '»';
2670 - $metasync_options['breadcrumbs']['home_label'] = isset($breadcrumbs_input['home_label']) ? sanitize_text_field($breadcrumbs_input['home_label']) : 'Home';
2671 - $metasync_options['breadcrumbs']['home_url'] = isset($breadcrumbs_input['home_url']) ? esc_url_raw($breadcrumbs_input['home_url']) : '';
2672 - $metasync_options['breadcrumbs']['prefix_text'] = isset($breadcrumbs_input['prefix_text']) ? sanitize_text_field($breadcrumbs_input['prefix_text']) : '';
2673 - $metasync_options['breadcrumbs']['archive_label_format'] = isset($breadcrumbs_input['archive_label_format']) ? sanitize_text_field($breadcrumbs_input['archive_label_format']) : '{name}';
3186 + foreach (array('enabled', 'disable_schema', 'show_current_page') as $breadcrumb_flag) {
3187 + if (isset($breadcrumbs_input[$breadcrumb_flag])) {
3188 + $metasync_options['breadcrumbs'][$breadcrumb_flag] = !empty($breadcrumbs_input[$breadcrumb_flag]);
3189 + }
3190 + }
3191 + // An empty separator (Custom radio selected blank) keeps the
3192 + // stored value instead of rendering a blank gap between links.
3193 + if (isset($breadcrumbs_input['separator'])) {
3194 + $breadcrumb_separator = sanitize_text_field($breadcrumbs_input['separator']);
3195 + if ('' !== $breadcrumb_separator) {
3196 + $metasync_options['breadcrumbs']['separator'] = $breadcrumb_separator;
3197 + }
3198 + }
3199 + if (isset($breadcrumbs_input['home_label'])) {
3200 + $metasync_options['breadcrumbs']['home_label'] = sanitize_text_field($breadcrumbs_input['home_label']);
3201 + }
3202 + if (isset($breadcrumbs_input['home_url'])) {
3203 + $metasync_options['breadcrumbs']['home_url'] = esc_url_raw($breadcrumbs_input['home_url']);
3204 + }
3205 + if (isset($breadcrumbs_input['prefix_text'])) {
3206 + $metasync_options['breadcrumbs']['prefix_text'] = sanitize_text_field($breadcrumbs_input['prefix_text']);
3207 + }
3208 + if (isset($breadcrumbs_input['archive_label_format'])) {
3209 + $metasync_options['breadcrumbs']['archive_label_format'] = sanitize_text_field($breadcrumbs_input['archive_label_format']);
3210 + }
2674 3211 }
2675 3212
2676 3213 // Open Graph / article toggle checkboxes live under common_meta_settings.
2677 3214 // Unchecked HTML checkboxes are absent from $_POST, so we explicitly
@@ -2692,9 +3229,9 @@
2692 3229 $metasync_options['common_meta_settings'] = [];
2693 3230 }
2694 3231 foreach ($og_toggle_fields as $field) {
2695 3232 $posted = isset($_POST['metasync_options']['common_meta_settings'][$field])
2696 - ? sanitize_text_field($_POST['metasync_options']['common_meta_settings'][$field])
3233 + ? sanitize_text_field(wp_unslash($_POST['metasync_options']['common_meta_settings'][$field]))
2697 3234 : '';
2698 3235 $metasync_options['common_meta_settings'][$field] = ($posted === 'true') ? 'true' : 'false';
2699 3236 }
2700 3237 }
@@ -2701,14 +3238,15 @@
2701 3238
2702 3239 if ($general_tab_submitted && isset($_POST['metasync_options']['general']['default_page_builder'])) {
2703 3240 require_once plugin_dir_path(dirname(__FILE__)) . 'custom-pages/class-metasync-html-to-builder-converter.php';
2704 3241 $available_builders = Metasync_HTML_To_Builder_Converter::get_available_builders();
2705 - $builder_value = sanitize_text_field($_POST['metasync_options']['general']['default_page_builder']);
3242 + $builder_value = sanitize_text_field(wp_unslash($_POST['metasync_options']['general']['default_page_builder']));
2706 3243 if (isset($available_builders[$builder_value]) && $available_builders[$builder_value]['detected']) {
2707 3244 $metasync_options['general']['default_page_builder'] = $builder_value;
2708 3245 } else {
2709 3246 $metasync_options['general']['default_page_builder'] = 'gutenberg';
2710 3247 if (isset($available_builders[$builder_value]) && !$available_builders[$builder_value]['detected']) {
3248 + /* translators: %s: page builder name. */
2711 3249 $validation_errors[] = sprintf(__('Builder "%s" is not detected on this site. Reverted to Gutenberg.', 'metasync'), $available_builders[$builder_value]['label']);
2712 3250 }
2713 3251 }
2714 3252 }
@@ -2713,10 +3251,13 @@
2713 3251 }
2714 3252 }
2715 3253
2716 3254 if ($whitelabel_tab_submitted && isset($_POST['metasync_options']['whitelabel'])) {
2717 - $gp = isset($_POST['metasync_options']['general']) && is_array($_POST['metasync_options']['general']) ? $_POST['metasync_options']['general'] : [];
2718 - $wp = isset($_POST['metasync_options']['whitelabel']) && is_array($_POST['metasync_options']['whitelabel']) ? $_POST['metasync_options']['whitelabel'] : [];
3255 + // Values are only previewed from the just-sanitised $metasync_options below; nothing here is stored.
3256 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- preview-only read
3257 + $gp = isset($_POST['metasync_options']['general']) && is_array($_POST['metasync_options']['general']) ? wp_unslash($_POST['metasync_options']['general']) : [];
3258 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- preview-only read
3259 + $wp = isset($_POST['metasync_options']['whitelabel']) && is_array($_POST['metasync_options']['whitelabel']) ? wp_unslash($_POST['metasync_options']['whitelabel']) : [];
2719 3260 $plugin_name = isset($gp['white_label_plugin_name']) ? trim((string) $gp['white_label_plugin_name']) : '';
2720 3261 $logo_light = isset($wp['logo_light']) ? trim((string) $wp['logo_light']) : '';
2721 3262 $logo_dark = isset($wp['logo_dark']) ? trim((string) $wp['logo_dark']) : '';
2722 3263 $author = isset($gp['white_label_plugin_author']) ? trim((string) $gp['white_label_plugin_author']) : '';
@@ -2732,16 +3273,67 @@
2732 3273 $validation_errors[] = 'Add at least one of: Plugin Name, Logo URL, or Author to save whitelabel settings.';
2733 3274 }
2734 3275 }
2735 3276
3277 + // Branding errors block the whole save: an invalid value must never
3278 + // be persisted, and the response carries the field keys so the client
3279 + // can render the error beside each offending input.
3280 + foreach ($branding_errors as $branding_error) {
3281 + $validation_errors[] = $branding_error;
3282 + }
3283 +
2736 3284 if (!empty($validation_errors)) {
3285 + $this->force_release_settings_recovery_lock();
2737 3286 wp_send_json_error([
2738 - 'errors' => $validation_errors
3287 + 'errors' => $validation_errors,
3288 + 'field_errors' => $branding_errors,
2739 3289 ]);
2740 3290 return;
2741 3291 }
2742 3292
3293 + // The analytics consent toggle posts at the top level (hidden fallback
3294 + // plus checkbox), not under metasync_options, so persist it here. This
3295 + // is the "turn it off at any time" path promised in the readme.
3296 + if ($general_tab_submitted && isset($_POST['metasync_analytics_opt_in'])) {
3297 + $consent = sanitize_key(wp_unslash($_POST['metasync_analytics_opt_in']));
3298 + update_option('metasync_analytics_opt_in', $consent === 'yes' ? 'yes' : 'no');
3299 + update_option('metasync_analytics_consent_explicit', 'yes');
3300 + }
3301 +
2743 3302 $old_options = Metasync::get_option('general') ?? [];
3303 +
3304 + // White-label branding is persisted twice — into the options and into
3305 + // the physical metasync.php header — so the header update is applied
3306 + // BEFORE any option write. On failure nothing has been persisted and
3307 + // both the file and the stored options still carry the previous
3308 + // branding; the save is rejected instead of ending up inconsistent.
3309 + $branding_headers_written = false;
3310 + $original_plugin_file_content = false;
3311 + $plugin_header_file = Metasync_Activator::plugin_header_file_path();
3312 + if ($general_tab_submitted || $whitelabel_tab_submitted) {
3313 + $original_plugin_file_content = file_get_contents($plugin_header_file);
3314 + $headers_applied = Metasync_Activator::apply_plugin_file_headers_for_save(
3315 + $metasync_options['general'],
3316 + is_array($old_options) ? $old_options : array()
3317 + );
3318 + if ($headers_applied === false) {
3319 + $this->force_release_settings_recovery_lock();
3320 + wp_send_json_error([
3321 + 'errors' => array(
3322 + 'White-label branding could not be written to the plugin file (metasync.php may be read-only or damaged). Nothing was saved; the previous branding is unchanged.',
3323 + ),
3324 + ]);
3325 + // The return is unreachable in WordPress (wp_send_json_error
3326 + // terminates the request) but keeps the handler safe under the
3327 + // non-terminating stubs the unit tests run with.
3328 + // @phpstan-ignore-next-line deadCode.unreachable
3329 + return;
3330 + }
3331 + if ($headers_applied === true && is_string($original_plugin_file_content)) {
3332 + $branding_headers_written = file_get_contents($plugin_header_file) !== $original_plugin_file_content;
3333 + }
3334 + }
3335 +
2744 3336 // Stored value is encrypted at rest (or legacy plaintext/empty). Keep it as-is
2745 3337 // for preserve/revert, and decrypt a separate copy for plaintext comparison.
2746 3338 $old_stored_api_key = $old_options['searchatlas_api_key'] ?? '';
2747 3339 $old_api_key = Metasync::get_searchatlas_api_key();
@@ -2785,14 +3377,19 @@
2785 3377
2786 3378 if ( $ping['connected'] === true ) {
2787 3379 $api_key_validated = true;
2788 3380 $is_connected = true;
2789 - if ( ! empty( $ping['otto_pixel_uuid'] ) ) {
3381 + // Trusted ping flow: only a real UUID is stored. The
3382 + // form itself can no longer submit this field, so this ping is
3383 + // one of the few remaining sanctioned write paths for it.
3384 + if ( ! empty( $ping['otto_pixel_uuid'] ) && Metasync::is_valid_uuid( $ping['otto_pixel_uuid'] ) ) {
2790 3385 $existing_uuid = $metasync_options['general']['otto_pixel_uuid'] ?? '';
2791 3386 if ( empty( $existing_uuid ) || $existing_uuid !== $ping['otto_pixel_uuid'] ) {
2792 3387 $metasync_options['general']['otto_pixel_uuid'] = sanitize_text_field( $ping['otto_pixel_uuid'] );
2793 3388 Metasync::set_option($metasync_options);
2794 3389 }
3390 + } elseif ( ! empty( $ping['otto_pixel_uuid'] ) ) {
3391 + error_log('MetaSync: ignoring invalid otto_pixel_uuid from connection ping (not a UUID)');
2795 3392 }
2796 3393 } else {
2797 3394 // POST succeeded but ping didn't confirm — keep the key, warn user
2798 3395 $api_key_validated = true;
@@ -2844,8 +3441,15 @@
2844 3441 $api_key_changed = $old_api_key !== $new_api_key;
2845 3442 $api_key_added = empty($old_api_key) && !empty($new_api_key);
2846 3443 $api_key_removed = !empty($old_api_key) && empty($new_api_key);
2847 3444
3445 + if ( $api_key_removed ) {
3446 + Metasync_PostHog::feature('searchatlas_disconnected', array(
3447 + 'method' => 'key_removed',
3448 + 'was_connected' => true,
3449 + ));
3450 + }
3451 +
2848 3452 if ( $api_key_validated === true ) {
2849 3453 # Use current_time('mysql') so the value is consistent with the
2850 3454 # cron heartbeat writer and renders correctly via time_elapsed_string.
2851 3455 $send_auth_token_timestamp = Metasync::get_option();
@@ -2879,9 +3483,10 @@
2879 3483 }
2880 3484
2881 3485 if (isset($_POST['metasync_options']['whitelabel'])) {
2882 3486
2883 - $whitelabel_data = $_POST['metasync_options']['whitelabel'];
3487 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every stored key below is validated/sanitised (FILTER_VALIDATE_URL + esc_url_raw for logos, sanitize_* for text).
3488 + $whitelabel_data = wp_unslash($_POST['metasync_options']['whitelabel']);
2884 3489 $existing_whitelabel = $metasync_options['whitelabel'] ?? [];
2885 3490
2886 3491 if (isset($whitelabel_data['logo'])) {
2887 3492 $logo_value = trim($whitelabel_data['logo']);
@@ -2922,9 +3527,9 @@
2922 3527 }
2923 3528 }
2924 3529
2925 3530 if (isset($whitelabel_data['settings_password'])) {
2926 - $password_value = trim($whitelabel_data['settings_password']);
3531 + $password_value = (string) $whitelabel_data['settings_password'];
2927 3532
2928 3533 $hide_settings_enabled = isset($whitelabel_data['hide_settings']) && $whitelabel_data['hide_settings'] == '1';
2929 3534
2930 3535 if ($hide_settings_enabled && empty($password_value)) {
@@ -2930,10 +3535,18 @@
2930 3535 if ($hide_settings_enabled && empty($password_value)) {
2931 3536 if (isset($existing_whitelabel['settings_password'])) {
2932 3537 $metasync_options['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'];
2933 3538 }
3539 + } elseif ($password_value === '') {
3540 + $metasync_options['whitelabel']['settings_password'] = '';
2934 3541 } else {
2935 - $metasync_options['whitelabel']['settings_password'] = Metasync::encrypt_secret(sanitize_text_field($password_value));
3542 + $encrypted_password = Metasync::encrypt_secret($password_value);
3543 + if (Metasync::is_encrypted_secret($encrypted_password) && !hash_equals($password_value, $encrypted_password)) {
3544 + $metasync_options['whitelabel']['settings_password'] = $encrypted_password;
3545 + } else {
3546 + $metasync_options['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'] ?? '';
3547 + $whitelabel_errors['settings_password'] = 'Unable to securely save the password. Please choose a different password.';
3548 + }
2936 3549 }
2937 3550 } else {
2938 3551 if (isset($existing_whitelabel['settings_password'])) {
2939 3552 $metasync_options['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'];
@@ -3014,9 +3627,9 @@
3014 3627
3015 3628 }
3016 3629
3017 3630 $redirect_url = isset($_GET['tab']) ?
3018 - admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-settings&tab='.$_GET['tab']) :
3631 + admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-settings&tab=' . sanitize_text_field(wp_unslash($_GET['tab']))) :
3019 3632 admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-settings');
3020 3633
3021 3634 // Also save edge cache settings if present in the payload (section renders inside this form)
3022 3635 if (class_exists('Metasync_Edge_Cache_Settings')) {
@@ -3023,8 +3636,9 @@
3023 3636 Metasync_Edge_Cache_Settings::save_from_post();
3024 3637 }
3025 3638
3026 3639 if ($general_tab_submitted && isset($_POST['metasync_llms_txt_settings'])) {
3640 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitised by sanitize_llms_txt_settings() before storage.
3027 3641 $llms_input = wp_unslash($_POST['metasync_llms_txt_settings']);
3028 3642 if (is_array($llms_input)) {
3029 3643 update_option('metasync_llms_txt_settings', $this->sanitize_llms_txt_settings($llms_input));
3030 3644 }
@@ -3029,8 +3643,37 @@
3029 3643 update_option('metasync_llms_txt_settings', $this->sanitize_llms_txt_settings($llms_input));
3030 3644 }
3031 3645 }
3032 3646
3647 + // The plugin file already carries the new branding; confirm the
3648 + // options write landed the same values. On divergence, roll the file
3649 + // back to the captured content so the Plugins screen never shows
3650 + // branding the stored options contradict, and report the save as
3651 + // rejected rather than a success.
3652 + if ($branding_headers_written) {
3653 + $stored_branding = Metasync::get_option('general');
3654 + $stored_branding = is_array($stored_branding) ? $stored_branding : array();
3655 + $proposed_branding = $metasync_options['general'];
3656 + foreach (Metasync_Activator::BRANDING_HEADER_FIELDS as $branding_key) {
3657 + if ((string) ($stored_branding[$branding_key] ?? '') !== (string) ($proposed_branding[$branding_key] ?? '')) {
3658 + $restored = Metasync_Activator::restore_plugin_file_content($plugin_header_file, $original_plugin_file_content);
3659 + $this->force_release_settings_recovery_lock();
3660 + wp_send_json_error([
3661 + 'errors' => array(
3662 + $restored
3663 + ? 'White-label branding could not be saved. The previous settings and plugin branding were left unchanged.'
3664 + : 'White-label branding could not be saved and the plugin file branding could not be rolled back (metasync.php may be read-only). Re-save once the file is writable so the plugin header matches the stored settings.',
3665 + ),
3666 + ]);
3667 + // The return is unreachable in WordPress (wp_send_json_error
3668 + // terminates the request) but keeps the handler safe under the
3669 + // non-terminating stubs the unit tests run with.
3670 + // @phpstan-ignore-next-line deadCode.unreachable
3671 + return;
3672 + }
3673 + }
3674 + }
3675 +
3033 3676 $response_payload = array(
3034 3677 'message' => 'Settings saved successfully!',
3035 3678 'redirect_url' => $redirect_url,
3036 3679 );
@@ -3064,8 +3707,10 @@
3064 3707 }
3065 3708
3066 3709 delete_transient('metasync_otto_js_detected');
3067 3710
3711 + $this->force_release_settings_recovery_lock();
3712 +
3068 3713 wp_send_json_success( $response_payload );
3069 3714 }
3070 3715
3071 3716 /**
@@ -3074,9 +3719,10 @@
3074 3719 * Moved from Metasync_Admin – the admin class now delegates here.
3075 3720 */
3076 3721 public function ajax_save_execution_settings()
3077 3722 {
3078 - if (!isset($_POST['execution_settings_nonce']) || !wp_verify_nonce($_POST['execution_settings_nonce'], 'metasync_execution_settings_nonce')) {
3723 + $execution_nonce = isset($_POST['execution_settings_nonce']) ? sanitize_text_field(wp_unslash($_POST['execution_settings_nonce'])) : '';
3724 + if (!wp_verify_nonce($execution_nonce, 'metasync_execution_settings_nonce')) {
3079 3725 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
3080 3726 return;
3081 3727 }
3082 3728
@@ -3170,9 +3816,579 @@
3170 3816 wp_send_json_error(array('message' => 'Failed to save settings. Please try again.'));
3171 3817 }
3172 3818 }
3173 3819
3820 + # ------------------------------------------------------------------
3821 + # Headless Mode settings on the Advanced tab
3822 + # ------------------------------------------------------------------
3823 + # Metasync_Headless_Config is the only sanitiser for these four values.
3824 + # Nothing here validates a domain, a policy or a prefix — it collects
3825 + # what was submitted, hands it to update_settings(), and then shows back
3826 + # whatever that stored, so a value the sanitiser refused reads as refused
3827 + # instead of being echoed back as though it saved.
3828 + # ------------------------------------------------------------------
3829 +
3174 3830 /**
3831 + * Field prefix shared by every control in the Headless Mode block.
3832 + *
3833 + * The controls sit inside the Advanced tab's outer form, which posts to
3834 + * options.php. Naming them outside `metasync_options[...]` keeps them out
3835 + * of sanitize() entirely, so an unrelated save of that form can neither
3836 + * store them unsanitised nor clear them.
3837 + */
3838 + const HEADLESS_FIELD_PREFIX = 'metasync_headless_';
3839 +
3840 + /**
3841 + * Nonce action and request field for the Headless Mode save.
3842 + */
3843 + const HEADLESS_NONCE_ACTION = 'metasync_headless_settings_nonce';
3844 + const HEADLESS_NONCE_FIELD = 'headless_settings_nonce';
3845 +
3846 + /**
3847 + * Render the Headless Mode section of the Advanced settings accordion.
3848 + *
3849 + * Values come from Metasync_Headless_Config::get_settings(), which is the
3850 + * sanitised stored state rather than anything the operator typed.
3851 + *
3852 + * @return void
3853 + */
3854 + public function render_headless_mode_section()
3855 + {
3856 + $settings = Metasync_Headless_Config::get_settings();
3857 + $enabled = !empty($settings['enabled']);
3858 + $active = Metasync_Headless_Config::is_active();
3859 + $prefix = self::HEADLESS_FIELD_PREFIX;
3860 +
3861 + $policy_labels = array(
3862 + Metasync_Headless_Config::SLASH_PRESERVE => 'Preserve — hand back whatever WordPress produced (default)',
3863 + Metasync_Headless_Config::SLASH_STRIP => 'Strip — no trailing slash',
3864 + Metasync_Headless_Config::SLASH_ADD => 'Add — always end with a trailing slash',
3865 + );
3866 + ?>
3867 + <div style="background: var(--dashboard-card-bg); padding: 20px; border-radius: 8px;">
3868 + <p style="color: var(--dashboard-text-secondary); margin: 0 0 20px 0;">
3869 + Headless Mode describes a separate frontend that serves the public site, so this plugin can build
3870 + URLs on that host instead of this one. With the mode off nothing here changes how the site behaves.
3871 + </p>
3872 +
3873 + <div style="background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.3); border-radius: 8px; padding: 16px; margin-bottom: 20px;">
3874 + <p style="color: var(--dashboard-text-secondary); margin: 0;">
3875 + Headless Mode is for sites where WordPress manages content and a separate frontend serves the public pages.
3876 + Set the public frontend URL below, then choose how MetaSync should build those public URLs.
3877 + </p>
3878 + </div>
3879 +
3880 + <div id="metasync-headless-settings-message" style="display: none; margin-bottom: 20px;"></div>
3881 +
3882 + <form id="metasync-headless-settings-form" method="post" data-headless-active="<?php echo $active ? '1' : '0'; ?>">
3883 + <?php wp_nonce_field(self::HEADLESS_NONCE_ACTION, self::HEADLESS_NONCE_FIELD); ?>
3884 +
3885 + <div style="background: var(--dashboard-card-bg-alt, rgba(255,255,255,0.05)); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
3886 + <label for="<?php echo esc_attr($prefix . 'enabled'); ?>" style="display: flex; align-items: center; gap: 10px; color: var(--dashboard-text-primary); font-weight: 500; cursor: pointer;">
3887 + <?php
3888 + # Hidden field first, checkbox second, same name. An unchecked
3889 + # checkbox posts nothing at all, and normalize() reads an absent
3890 + # `enabled` key as false — so without this pair, a submission that
3891 + # simply did not include the checkbox would switch the mode off as
3892 + # a side effect. With it, "off" arrives as an explicit 0.
3893 + ?>
3894 + <input type="hidden" name="<?php echo esc_attr($prefix . 'enabled'); ?>" value="0" />
3895 + <input type="checkbox"
3896 + id="<?php echo esc_attr($prefix . 'enabled'); ?>"
3897 + name="<?php echo esc_attr($prefix . 'enabled'); ?>"
3898 + value="1"
3899 + style="width: 18px; height: 18px; cursor: pointer;"
3900 + <?php checked($enabled, true); ?> />
3901 + <span>Enable Headless Mode</span>
3902 + </label>
3903 +
3904 + <div style="background: rgba(245, 158, 11, 0.1); border: 1px solid rgba(245, 158, 11, 0.3); border-radius: 8px; padding: 16px; margin: 16px 0 0 0;">
3905 + <p style="color: var(--dashboard-text-secondary); margin: 0; font-size: 13px;">
3906 + <strong style="color: var(--dashboard-warning, #f59e0b);">Changing this can affect your public frontend.</strong><br />
3907 + When Headless Mode is active, the GraphQL SEO field and generated URLs follow the settings below.
3908 + Coordinate with whoever maintains the frontend before disabling it or changing its public URL.
3909 + </p>
3910 + </div>
3911 +
3912 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 12px 0 0 0;">
3913 + Current state:
3914 + <strong style="color: var(--dashboard-text-primary);"><?php echo $enabled ? 'enabled' : 'disabled'; ?></strong><?php
3915 + if ($enabled && !$active) {
3916 + ?> &mdash; but not yet in effect: a frontend domain is required before any headless URL can be built.<?php
3917 + }
3918 + ?>
3919 + </p>
3920 + </div>
3921 +
3922 + <div style="background: var(--dashboard-card-bg-alt, rgba(255,255,255,0.05)); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
3923 + <div style="margin-bottom: 24px;">
3924 + <label for="<?php echo esc_attr($prefix . 'frontend_domain'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px; font-weight: 500;">
3925 + Public frontend URL:
3926 + </label>
3927 + <input type="url"
3928 + id="<?php echo esc_attr($prefix . 'frontend_domain'); ?>"
3929 + name="<?php echo esc_attr($prefix . 'frontend_domain'); ?>"
3930 + value="<?php echo esc_attr($settings['frontend_domain']); ?>"
3931 + placeholder="https://www.example.com"
3932 + style="width: 100%; max-width: 420px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg); color: var(--dashboard-text-primary);" />
3933 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3934 + Scheme and host only &mdash; for example <code>https://www.example.com</code>. A path is
3935 + refused: <code>https://example.com/blog</code> will not save, because only per-post-type
3936 + prefixes exist and a shared path segment could never be put back on terms, archives or
3937 + the homepage. Use a path prefix below instead. A bare host is treated as https.
3938 + </p>
3939 + </div>
3940 +
3941 + <div style="margin-bottom: 24px;">
3942 + <label for="<?php echo esc_attr($prefix . 'slash_policy'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px; font-weight: 500;">
3943 + Trailing Slash Policy:
3944 + </label>
3945 + <select id="<?php echo esc_attr($prefix . 'slash_policy'); ?>"
3946 + name="<?php echo esc_attr($prefix . 'slash_policy'); ?>"
3947 + style="max-width: 420px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg); color: var(--dashboard-text-primary);">
3948 + <?php foreach (Metasync_Headless_Config::slash_policies() as $policy) { ?>
3949 + <option value="<?php echo esc_attr($policy); ?>" <?php selected($settings['slash_policy'], $policy); ?>>
3950 + <?php echo esc_html(isset($policy_labels[$policy]) ? $policy_labels[$policy] : $policy); ?>
3951 + </option>
3952 + <?php } ?>
3953 + </select>
3954 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3955 + Choose how generated public URLs end. For example, <code>/about</code> stays unchanged with Preserve,
3956 + becomes <code>/about</code> with Strip, or <code>/about/</code> with Add. Match your frontend router.
3957 + </p>
3958 + </div>
3959 +
3960 + <div>
3961 + <label for="<?php echo esc_attr($prefix . 'path_prefixes'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px; font-weight: 500;">
3962 + Path Prefixes:
3963 + </label>
3964 + <textarea id="<?php echo esc_attr($prefix . 'path_prefixes'); ?>"
3965 + name="<?php echo esc_attr($prefix . 'path_prefixes'); ?>"
3966 + rows="4"
3967 + placeholder="post = blog"
3968 + style="width: 100%; max-width: 420px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg); color: var(--dashboard-text-primary); font-family: monospace;"><?php echo esc_textarea(self::format_headless_path_prefixes($settings['path_prefixes'])); ?></textarea>
3969 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3970 + Map WordPress content types to frontend URL folders:
3971 + </p>
3972 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 8px 0 0 0;">
3973 + One per line, for example <code>post = blog</code> produces <code>/blog/example-post</code>.
3974 + Leave empty when your frontend uses the same paths as WordPress.
3975 + </p>
3976 + </div>
3977 +
3978 + <details style="margin-top: 24px;">
3979 + <summary style="color: var(--dashboard-text-primary); font-weight: 500; cursor: pointer;">Advanced refresh settings</summary>
3980 + <div style="padding-top: 16px;">
3981 + <label for="<?php echo esc_attr($prefix . 'refresh_interval_minutes'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px;">
3982 + Background refresh interval (minutes):
3983 + </label>
3984 + <input type="number"
3985 + id="<?php echo esc_attr($prefix . 'refresh_interval_minutes'); ?>"
3986 + name="<?php echo esc_attr($prefix . 'refresh_interval_minutes'); ?>"
3987 + value="<?php echo esc_attr($settings['refresh_interval_minutes']); ?>"
3988 + min="<?php echo esc_attr((string) Metasync_Headless_Config::REFRESH_INTERVAL_MIN_MINUTES); ?>"
3989 + max="<?php echo esc_attr((string) Metasync_Headless_Config::REFRESH_INTERVAL_MAX_MINUTES); ?>"
3990 + step="1"
3991 + style="width: 100%; max-width: 200px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg); color: var(--dashboard-text-primary);" />
3992 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3993 + How often the background job re-checks a URL's stored OTTO data against OTTO directly, for
3994 + any post or term OTTO's deployment webhook missed. Does not affect the webhook, which stays
3995 + the primary, immediate update path. Between
3996 + <?php echo esc_html((string) Metasync_Headless_Config::REFRESH_INTERVAL_MIN_MINUTES); ?> and
3997 + <?php echo esc_html((string) Metasync_Headless_Config::REFRESH_INTERVAL_MAX_MINUTES); ?> minutes;
3998 + defaults to <?php echo esc_html((string) Metasync_Headless_Config::REFRESH_INTERVAL_DEFAULT_MINUTES); ?>.
3999 + </p>
4000 + </div>
4001 + </details>
4002 + </div>
4003 +
4004 + <button type="button"
4005 + id="metasync-headless-settings-save-btn"
4006 + class="metasync-btn-primary"
4007 + style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 12px 24px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);">
4008 + <span class="save-text">Save Headless Settings</span>
4009 + </button>
4010 + </form>
4011 + </div>
4012 + <script>
4013 + jQuery(document).ready(function ($) {
4014 + // Click, not submit: this form is nested inside #metaSyncGeneralSetting,
4015 + // so the browser drops the inner form tag and a submit handler would
4016 + // never fire on its own.
4017 + var $saveBtn = $('#metasync-headless-settings-save-btn');
4018 + var $message = $('#metasync-headless-settings-message');
4019 + var headlessModeWasActive = <?php echo $active ? 'true' : 'false'; ?>;
4020 + var headlessDisableWarning = 'Turning off Headless Mode removes the metasyncSeo field from the GraphQL schema. Any frontend querying it will error until it is redeployed. Continue?';
4021 + var headlessDomainWarning = 'The metasyncSeo field stays in the GraphQL schema, but every URL it returns will be empty until a frontend domain is set again. Continue?';
4022 + var headlessModeSavedSettings = <?php echo wp_json_encode(array(
4023 + 'enabled' => !empty($settings['enabled']),
4024 + 'frontend_domain' => $settings['frontend_domain'],
4025 + 'slash_policy' => $settings['slash_policy'],
4026 + 'path_prefixes' => self::format_headless_path_prefixes($settings['path_prefixes']),
4027 + 'refresh_interval_minutes' => $settings['refresh_interval_minutes'],
4028 + )); ?>;
4029 +
4030 + // This mirrors normalize_domain()'s shape rules so a value the
4031 + // server will refuse cannot look active here. Keep this deliberately
4032 + // small: it is a confirmation decision, not a client-side validator,
4033 + // and the server remains authoritative. Mirrors can drift.
4034 + function isUsableFrontendDomain(value) {
4035 + // PHP's trim() strips only space, tab, newline, carriage return,
4036 + // NUL and vertical tab, while $.trim() and String.trim() strip the
4037 + // whole Unicode set. Trimming a non-breaking space or BOM that the
4038 + // server keeps would make a pasted domain look usable while
4039 + // normalize_domain() rejects it — the guard stays silent and the
4040 + // mode deactivates anyway. Match the server charlist exactly.
4041 + var candidate = (value || '').replace(/^[ \t\n\r\0\x0B]+|[ \t\n\r\0\x0B]+$/g, '');
4042 + var parsed;
4043 + var authority;
4044 + var host;
4045 + var path;
4046 +
4047 + if (candidate === '') {
4048 + return false;
4049 + }
4050 +
4051 + if (candidate.indexOf('//') === -1) {
4052 + candidate = 'https://' + candidate.replace(/^\/+/, '');
4053 + }
4054 +
4055 + parsed = candidate.match(/^(?:([a-z][a-z0-9+.-]*):)?\/\/([^/?#]*)([^?#]*)/i);
4056 + if (!parsed || (parsed[1] && parsed[1].toLowerCase() !== 'http' && parsed[1].toLowerCase() !== 'https')) {
4057 + return false;
4058 + }
4059 +
4060 + path = parsed[3] || '';
4061 + if (path.replace(/^\/+|\/+$/g, '') !== '') {
4062 + return false;
4063 + }
4064 +
4065 + authority = parsed[2].replace(/^.*@/, '');
4066 + if (authority.charAt(0) === '[') {
4067 + return /^\[[0-9a-f:.]+\](?::\d+)?$/i.test(authority);
4068 + }
4069 +
4070 + host = authority.replace(/:\d+$/, '');
4071 + if (!/^[\p{L}\p{N}._-]+$/u.test(host) || host.indexOf('..') !== -1) {
4072 + return false;
4073 + }
4074 +
4075 + return !/^[.-]|[.-]$/.test(host) && /[\p{L}\p{N}]/u.test(host);
4076 + }
4077 +
4078 + function showMessage(lines, type) {
4079 + var $list = $('<div/>');
4080 + $.each(lines, function (i, line) {
4081 + $list.append($('<p/>').css('margin', i === 0 ? '0' : '8px 0 0 0').text(line));
4082 + });
4083 +
4084 + $message
4085 + .css({
4086 + 'background': type === 'success' ? 'rgba(34, 197, 94, 0.1)' : 'rgba(239, 68, 68, 0.1)',
4087 + 'border': '1px solid ' + (type === 'success' ? 'rgba(34, 197, 94, 0.3)' : 'rgba(239, 68, 68, 0.3)'),
4088 + 'color': type === 'success' ? '#22c55e' : '#ef4444',
4089 + 'padding': '12px 16px',
4090 + 'border-radius': '6px',
4091 + 'font-size': '14px',
4092 + 'line-height': '1.5'
4093 + })
4094 + .empty()
4095 + .append($list)
4096 + .show();
4097 + }
4098 +
4099 + // Repaint from what was actually stored, never from what was typed, so a
4100 + // refused value reads as refused rather than looking saved.
4101 + function applyStoredSettings(settings) {
4102 + if (!settings) {
4103 + return;
4104 + }
4105 + headlessModeSavedSettings = $.extend({}, settings);
4106 + headlessModeWasActive = !!settings.enabled && isUsableFrontendDomain(settings.frontend_domain);
4107 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>').prop('checked', !!settings.enabled);
4108 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>').val(settings.frontend_domain);
4109 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'slash_policy'); ?>').val(settings.slash_policy);
4110 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'path_prefixes'); ?>').val(settings.path_prefixes);
4111 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'refresh_interval_minutes'); ?>').val(settings.refresh_interval_minutes);
4112 + }
4113 +
4114 + $saveBtn.on('click', function (e) {
4115 + e.preventDefault();
4116 +
4117 + var data = {
4118 + action: 'metasync_save_headless_settings',
4119 + <?php echo esc_js(self::HEADLESS_NONCE_FIELD); ?>: $('#<?php echo esc_js(self::HEADLESS_NONCE_FIELD); ?>').val(),
4120 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>').is(':checked') ? '1' : '0',
4121 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>').val(),
4122 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'slash_policy'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'slash_policy'); ?>').val(),
4123 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'path_prefixes'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'path_prefixes'); ?>').val(),
4124 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'refresh_interval_minutes'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'refresh_interval_minutes'); ?>').val()
4125 + };
4126 +
4127 + var submittedModeIsEnabled = data['<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>'] === '1';
4128 + var submittedDomain = data['<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>'];
4129 + var submittedModeIsActive = submittedModeIsEnabled
4130 + && isUsableFrontendDomain(submittedDomain);
4131 +
4132 + if (submittedModeIsEnabled && !submittedModeIsActive) {
4133 + showMessage(['Enter a valid public frontend URL before enabling Headless Mode. Use a scheme and host only, for example https://www.example.com.'], 'error');
4134 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>').trigger('focus');
4135 + return;
4136 + }
4137 +
4138 + var confirmationMessage = submittedModeIsEnabled ? headlessDomainWarning : headlessDisableWarning;
4139 +
4140 + if (headlessModeWasActive && !submittedModeIsActive && !window.confirm(confirmationMessage)) {
4141 + applyStoredSettings(headlessModeSavedSettings);
4142 + return;
4143 + }
4144 +
4145 + $saveBtn.prop('disabled', true);
4146 + $saveBtn.find('.save-text').text('Saving...');
4147 + $message.hide();
4148 +
4149 + $.ajax({
4150 + url: window.ajaxurl,
4151 + type: 'POST',
4152 + data: data,
4153 + success: function (response) {
4154 + if (response && response.success) {
4155 + applyStoredSettings(response.data.settings);
4156 + var lines = [response.data.message];
4157 + if (response.data.notes && response.data.notes.length) {
4158 + lines = lines.concat(response.data.notes);
4159 + }
4160 + showMessage(lines, response.data.notes && response.data.notes.length ? 'error' : 'success');
4161 + } else {
4162 + showMessage([(response && response.data && response.data.message) || 'Error saving Headless Mode settings.'], 'error');
4163 + }
4164 + $saveBtn.prop('disabled', false);
4165 + $saveBtn.find('.save-text').text('Save Headless Settings');
4166 + },
4167 + error: function () {
4168 + showMessage(['An error occurred while saving Headless Mode settings. Please try again.'], 'error');
4169 + $saveBtn.prop('disabled', false);
4170 + $saveBtn.find('.save-text').text('Save Headless Settings');
4171 + }
4172 + });
4173 + });
4174 + });
4175 + </script>
4176 + <?php
4177 + }
4178 +
4179 + /**
4180 + * Render the stored post-type => prefix map back into the textarea format.
4181 + *
4182 + * @param mixed $prefixes Stored prefix map.
4183 + * @return string One `post_type = prefix` entry per line.
4184 + */
4185 + public static function format_headless_path_prefixes($prefixes)
4186 + {
4187 + if (!is_array($prefixes)) {
4188 + return '';
4189 + }
4190 +
4191 + $lines = array();
4192 +
4193 + foreach ($prefixes as $post_type => $prefix) {
4194 + $lines[] = $post_type . ' = ' . $prefix;
4195 + }
4196 +
4197 + return implode("\n", $lines);
4198 + }
4199 +
4200 + /**
4201 + * Read the textarea format back into a post-type => prefix map.
4202 + *
4203 + * One entry per line, `post_type = prefix`. A plain text format is enough
4204 + * for a demo and needs no repeater UI. A line with no `=` names no post
4205 + * type, so it is skipped rather than guessed at; everything that does
4206 + * survive is still handed to Metasync_Headless_Config::normalize(), which
4207 + * is what actually decides whether a post type and prefix are usable.
4208 + *
4209 + * @param mixed $text Raw textarea contents.
4210 + * @return array<string,string> Unsanitised map, keyed by post type.
4211 + */
4212 + public static function parse_headless_path_prefixes($text)
4213 + {
4214 + $map = array();
4215 +
4216 + if (!is_string($text)) {
4217 + return $map;
4218 + }
4219 +
4220 + foreach (preg_split('/\R/', $text) as $line) {
4221 + if (strpos($line, '=') === false) {
4222 + continue;
4223 + }
4224 +
4225 + list($post_type, $prefix) = explode('=', $line, 2);
4226 +
4227 + $post_type = trim($post_type);
4228 + if ($post_type === '') {
4229 + continue;
4230 + }
4231 +
4232 + $map[$post_type] = trim($prefix);
4233 + }
4234 +
4235 + return $map;
4236 + }
4237 +
4238 + /**
4239 + * Merge a Headless Mode submission over the stored settings and persist it.
4240 + *
4241 + * Additive on purpose: a key the request did not carry keeps whatever is
4242 + * already stored. normalize() treats an absent `enabled` as false, so a
4243 + * submission that omitted the checkbox would otherwise switch the mode off
4244 + * as a side effect of saving something else. The hidden field in front of
4245 + * the checkbox is the other half of that guard — it makes "off" an explicit
4246 + * `0` rather than an absence, so unticking the box still works.
4247 + *
4248 + * Sanitising is left entirely to Metasync_Headless_Config.
4249 + *
4250 + * @param mixed $submitted Raw request data, normally $_POST.
4251 + * @return array The settings that were actually stored.
4252 + */
4253 + public function save_headless_settings($submitted)
4254 + {
4255 + if (!is_array($submitted)) {
4256 + $submitted = array();
4257 + }
4258 +
4259 + $prefix = self::HEADLESS_FIELD_PREFIX;
4260 + $input = Metasync_Headless_Config::get_settings();
4261 +
4262 + if (isset($submitted[$prefix . 'enabled'])) {
4263 + $input['enabled'] = $submitted[$prefix . 'enabled'];
4264 + }
4265 +
4266 + if (isset($submitted[$prefix . 'frontend_domain'])) {
4267 + $input['frontend_domain'] = wp_unslash($submitted[$prefix . 'frontend_domain']);
4268 + }
4269 +
4270 + if (isset($submitted[$prefix . 'slash_policy'])) {
4271 + $input['slash_policy'] = $submitted[$prefix . 'slash_policy'];
4272 + }
4273 +
4274 + if (isset($submitted[$prefix . 'path_prefixes'])) {
4275 + $input['path_prefixes'] = self::parse_headless_path_prefixes(
4276 + wp_unslash($submitted[$prefix . 'path_prefixes'])
4277 + );
4278 + }
4279 +
4280 + if (isset($submitted[$prefix . 'refresh_interval_minutes'])) {
4281 + $input['refresh_interval_minutes'] = $submitted[$prefix . 'refresh_interval_minutes'];
4282 + }
4283 +
4284 + return Metasync_Headless_Config::update_settings($input);
4285 + }
4286 +
4287 + /**
4288 + * AJAX handler for the Headless Mode block on the Advanced tab.
4289 + *
4290 + * Same nonce-then-capability shape as the other Advanced tab handlers.
4291 + *
4292 + * @return void
4293 + */
4294 + /**
4295 + * Did the last write actually land?
4296 + *
4297 + * update_settings() hands back what it meant to store, not proof that it
4298 + * reached the database: a full disk, a failing object cache or a read-only
4299 + * replica all produce a silent no-op. Telling an operator their headless
4300 + * settings saved when they did not is worse than telling them nothing —
4301 + * they would go on to wonder why the frontend sees none of it.
4302 + *
4303 + * @param array $intended What the save call reported storing.
4304 + * @return bool
4305 + */
4306 + private function headless_settings_persisted(array $intended)
4307 + {
4308 + Metasync_Headless_Config::flush();
4309 +
4310 + return Metasync_Headless_Config::get_settings() == $intended;
4311 + }
4312 +
4313 + public function ajax_save_headless_settings()
4314 + {
4315 + $prefix = self::HEADLESS_FIELD_PREFIX;
4316 +
4317 + $headless_nonce = isset($_POST[self::HEADLESS_NONCE_FIELD]) ? sanitize_text_field(wp_unslash($_POST[self::HEADLESS_NONCE_FIELD])) : '';
4318 + if (!wp_verify_nonce($headless_nonce, self::HEADLESS_NONCE_ACTION)) {
4319 + wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
4320 + } elseif (!Metasync::current_user_has_plugin_access()) {
4321 + wp_send_json_error(array('message' => 'Insufficient permissions to save settings.'));
4322 + } else {
4323 + // phpcs:disable WordPress.Security.ValidatedSanitizedInput -- sanitised via Metasync_Headless_Config::normalize_domain() inside save_headless_settings().
4324 + $submitted_domain = isset($_POST[$prefix . 'frontend_domain'])
4325 + ? trim((string) wp_unslash($_POST[$prefix . 'frontend_domain']))
4326 + : '';
4327 + // phpcs:enable WordPress.Security.ValidatedSanitizedInput
4328 +
4329 + $stored = $this->save_headless_settings($_POST);
4330 +
4331 + if (!$this->headless_settings_persisted($stored)) {
4332 + wp_send_json_error(array(
4333 + 'message' => 'Those settings could not be saved. Nothing has changed — please try again, and check the site error log if it keeps happening.',
4334 + ));
4335 + }
4336 +
4337 + # Say so when the sanitiser refused something, rather than leaving the
4338 + # operator to spot that a field came back empty.
4339 + $notes = array();
4340 +
4341 + if ($submitted_domain !== '' && $stored['frontend_domain'] === '') {
4342 + $notes[] = 'That frontend domain was not saved. Give a scheme and host only, with no path — for example https://www.example.com.';
4343 + }
4344 +
4345 + if (!empty($stored['enabled']) && $stored['frontend_domain'] === '') {
4346 + $notes[] = 'Headless Mode is switched on but has no usable frontend domain, so nothing behaves as headless yet.';
4347 + }
4348 +
4349 + wp_send_json_success(array(
4350 + 'message' => 'Headless Mode settings saved.',
4351 + 'notes' => $notes,
4352 + 'settings' => array(
4353 + 'enabled' => !empty($stored['enabled']),
4354 + 'frontend_domain' => $stored['frontend_domain'],
4355 + 'slash_policy' => $stored['slash_policy'],
4356 + 'path_prefixes' => self::format_headless_path_prefixes($stored['path_prefixes']),
4357 + 'refresh_interval_minutes' => $stored['refresh_interval_minutes'],
4358 + ),
4359 + ));
4360 + }
4361 + }
4362 +
4363 + /**
4364 + * How many indexation rules a SEO Controls save actually changed.
4365 + *
4366 + * Older saves stored real booleans, this handler stores 'true'/'false'
4367 + * strings, so the stored side is normalised before comparing.
4368 + *
4369 + * @param array $before Stored seo_controls before the save.
4370 + * @param array $after seo_controls as just saved ('true'/'false').
4371 + * @param array $fields Field names the save handled.
4372 + * @return int
4373 + */
4374 + public static function count_index_rule_changes(array $before, array $after, array $fields)
4375 + {
4376 + $changed = 0;
4377 + foreach ($fields as $field) {
4378 + if (strpos($field, 'index_') !== 0 && $field !== 'noindex_empty_archives') {
4379 + continue;
4380 + }
4381 + $was = filter_var($before[$field] ?? false, FILTER_VALIDATE_BOOLEAN);
4382 + $now = ($after[$field] ?? 'false') === 'true';
4383 + if ($was !== $now) {
4384 + $changed++;
4385 + }
4386 + }
4387 + return $changed;
4388 + }
4389 +
4390 + /**
3175 4391 * AJAX handler for saving SEO / Indexation Control settings.
3176 4392 *
3177 4393 * Moved from Metasync_Admin – the admin class now delegates here.
3178 4394 */
@@ -3178,13 +4394,26 @@
3178 4394 */
3179 4395 public function meta_sync_save_seo_controls()
3180 4396 {
3181 4397 try {
3182 - if (!isset($_POST['meta_sync_seo_controls_nonce']) || !wp_verify_nonce($_POST['meta_sync_seo_controls_nonce'], 'meta_sync_seo_controls_nonce')) {
4398 + $seo_controls_nonce = isset($_POST['meta_sync_seo_controls_nonce']) ? sanitize_text_field(wp_unslash($_POST['meta_sync_seo_controls_nonce'])) : '';
4399 + if (!wp_verify_nonce($seo_controls_nonce, 'meta_sync_seo_controls_nonce')) {
3183 4400 wp_send_json_error(array('message' => 'Invalid nonce'));
3184 4401 return;
3185 4402 }
3186 4403
4404 + // Same nonce-then-capability shape as the other settings save
4405 + // handlers: the nonce is not authorization, so a logged-in user
4406 + // without plugin access must still be refused here.
4407 + if (!Metasync::current_user_has_plugin_access()) {
4408 + wp_send_json_error(array('message' => 'Insufficient permissions.'), 403);
4409 + // The return is unreachable in WordPress (wp_send_json_error
4410 + // terminates the request) but keeps the handler safe under the
4411 + // non-terminating stubs the unit tests run with.
4412 + // @phpstan-ignore-next-line deadCode.unreachable
4413 + return;
4414 + }
4415 +
3187 4416 $current_options = Metasync::get_option();
3188 4417
3189 4418 $original_options = json_decode(json_encode($current_options), true);
3190 4419
@@ -3234,8 +4463,22 @@
3234 4463 $result = true;
3235 4464 }
3236 4465
3237 4466 if ($result && $bing_save_result) {
4467 + // Consent-gated analytics: only when an indexation rule really
4468 + // changed. Firing from the options sanitize callback counted
4469 + // every unrelated write of metasync_options as a change.
4470 + $rules_changed = self::count_index_rule_changes(
4471 + $original_options['seo_controls'] ?? array(),
4472 + $current_options['seo_controls'],
4473 + $seo_control_fields
4474 + );
4475 + if ($rules_changed > 0) {
4476 + Metasync_PostHog::feature('indexation_rule_changed', array(
4477 + 'rules_changed' => $rules_changed,
4478 + ));
4479 + }
4480 +
3238 4481 $success_message = 'Indexation Control settings saved successfully!';
3239 4482 if (isset($_POST['metasync_bing_api_key_inline'])) {
3240 4483 $success_message = 'Indexation Control and Bing Instant Indexing settings saved successfully!';
3241 4484 }
@@ -3250,10 +4493,9 @@
3250 4493 }
3251 4494 wp_send_json_error(array('message' => $error_message));
3252 4495 }
3253 4496 } catch (Exception $e) {
3254 - error_log('Indexation Control AJAX Exception: ' . $e->getMessage());
3255 - error_log('Indexation Control AJAX Stack trace: ' . $e->getTraceAsString());
4497 + error_log('Indexation Control AJAX Exception: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3256 4498 wp_send_json_error(array('message' => 'Error saving settings: ' . $e->getMessage()));
3257 4499 }
3258 4500 }
3259 4501
@@ -3265,12 +4507,12 @@
3265 4507 * @return bool True on success, false on failure
3266 4508 */
3267 4509 public function save_bing_inline_settings_ajax()
3268 4510 {
3269 - $api_key = isset($_POST['metasync_bing_api_key_inline']) ? sanitize_text_field(wp_unslash($_POST['metasync_bing_api_key_inline'])) : '';
3270 - $endpoint = isset($_POST['metasync_bing_endpoint_inline']) ? sanitize_text_field(wp_unslash($_POST['metasync_bing_endpoint_inline'])) : 'indexnow';
3271 - $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'])) : [];
3272 - $disable_other_plugins = isset($_POST['metasync_bing_disable_other_plugins_inline']) ? true : false;
4511 + $api_key = isset($_POST['metasync_bing_api_key_inline']) ? sanitize_text_field(wp_unslash($_POST['metasync_bing_api_key_inline'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller verifies the SEO controls nonce first
4512 + $endpoint = isset($_POST['metasync_bing_endpoint_inline']) ? sanitize_text_field(wp_unslash($_POST['metasync_bing_endpoint_inline'])) : 'indexnow'; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller verifies the SEO controls nonce first
4513 + $post_types = isset($_POST['metasync_bing_post_types_inline']) && is_array($_POST['metasync_bing_post_types_inline']) ? array_map('sanitize_title', wp_unslash($_POST['metasync_bing_post_types_inline'])) : []; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller verifies the SEO controls nonce first
4514 + $disable_other_plugins = isset($_POST['metasync_bing_disable_other_plugins_inline']) ? true : false; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller verifies the SEO controls nonce first
3273 4515
3274 4516 $existing_settings = get_option('metasync_options_bing_instant_indexing', []);
3275 4517
3276 4518 $new_settings = [
@@ -3294,9 +4536,9 @@
3294 4536 $safe_key = sanitize_file_name( $api_key );
3295 4537 $file_path = ABSPATH . $safe_key . '.txt';
3296 4538 $real_path = realpath( ABSPATH );
3297 4539 if ( false === $real_path || 0 !== strpos( realpath( dirname( $file_path ) ), $real_path ) ) {
3298 - error_log( 'Bing IndexNow: Refusing to write outside ABSPATH' );
4540 + error_log( 'Bing IndexNow: Refusing to write outside ABSPATH' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3299 4541 return false;
3300 4542 }
3301 4543 // serve the key virtually via template_redirect instead of
3302 4544 // writing a physical {key}.txt. An on-disk file is served statically
@@ -3309,9 +4551,9 @@
3309 4551 }
3310 4552 Metasync_Bing_Instant_Index::register_virtual_key($safe_key);
3311 4553
3312 4554 if (file_exists($file_path)) {
3313 - @unlink($file_path);
4555 + @wp_delete_file($file_path);
3314 4556 }
3315 4557
3316 4558 $current_options = Metasync::get_option();
3317 4559 if (!isset($current_options['seo_controls']['enable_binginstantindex']) || $current_options['seo_controls']['enable_binginstantindex'] !== 'true') {