PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | includes/class-metasync-settings-registration.php +1214 -328 2.6.24 → 2.7.1 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;
@@ -234,9 +343,9 @@
234 343 $value = isset($settings['max_posts']) ? (int) $settings['max_posts'] : 50;
235 344 echo '<div class="llms-txt-dependent">';
236 345 printf(
237 346 '<input type="number" id="llms_txt_max_posts" name="metasync_llms_txt_settings[max_posts]" min="1" max="500" value="%d" />',
238 - $value
347 + absint($value)
239 348 );
240 349 echo '</div>';
241 350 },
242 351 $page_slug . '_general',
@@ -307,9 +416,9 @@
307 416 $value = isset($settings['max_posts_full']) ? (int) $settings['max_posts_full'] : 25;
308 417 echo '<div class="llms-full-dependent">';
309 418 printf(
310 419 '<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
420 + absint($value)
312 421 );
313 422 echo '<p class="description">' . esc_html__('Maximum posts for /llms-full.txt (lower than summary to protect server performance).', 'metasync') . '</p>';
314 423 echo '</div>';
315 424 },
@@ -345,12 +454,12 @@
345 454 'Disallow Empty Archives',
346 455 function() use ($option_key) {
347 456 $noindex_empty_archives = Metasync::get_option('seo_controls')['noindex_empty_archives'] ?? 'false';
348 457 printf(
349 - '<input type="checkbox" id="noindex_empty_archives" name="' . $option_key . '[seo_controls][noindex_empty_archives]" value="true" %s />',
458 + '<input type="checkbox" id="noindex_empty_archives" name="' . esc_attr($option_key) . '[seo_controls][noindex_empty_archives]" value="true" %s />',
350 459 isset($noindex_empty_archives) && $noindex_empty_archives == 'true' ? 'checked' : ''
351 460 );
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>');
461 + echo '<span class="description"><strong>Empty Archives:</strong> When checked, automatically adds noindex to category, tag, author, and format archive pages that have no posts. Once posts are added to these archives, they will automatically be allowed for indexing. This prevents thin content pages from being indexed.</span>';
353 462 },
354 463 $page_slug . '_seo-controls',
355 464 $SECTION_SEO_CONTROLS
356 465 );
@@ -360,12 +469,12 @@
360 469 'Disallow Date Archives',
361 470 function() use ($option_key) {
362 471 $index_date_archives = Metasync::get_option('seo_controls')['index_date_archives'] ?? 'false';
363 472 printf(
364 - '<input type="checkbox" id="index_date_archives" name="' . $option_key . '[seo_controls][index_date_archives]" value="true" %s />',
473 + '<input type="checkbox" id="index_date_archives" name="' . esc_attr($option_key) . '[seo_controls][index_date_archives]" value="true" %s />',
365 474 isset($index_date_archives) && $index_date_archives == 'true' ? 'checked' : ''
366 475 );
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>');
476 + echo '<span class="description"><strong>Date Archives:</strong> When checked, prevents search engines from indexing date-based archive pages (e.g., /2024/01/, /2024/01/15/). These pages often have thin content and can dilute your site\'s SEO value. Recommended for most sites.</span>';
368 477 },
369 478 $page_slug . '_seo-controls',
370 479 $SECTION_SEO_CONTROLS
371 480 );
@@ -375,12 +484,12 @@
375 484 'Disallow Tag Archives',
376 485 function() use ($option_key) {
377 486 $index_tag_archives = Metasync::get_option('seo_controls')['index_tag_archives'] ?? 'false';
378 487 printf(
379 - '<input type="checkbox" id="index_tag_archives" name="' . $option_key . '[seo_controls][index_tag_archives]" value="true" %s />',
488 + '<input type="checkbox" id="index_tag_archives" name="' . esc_attr($option_key) . '[seo_controls][index_tag_archives]" value="true" %s />',
380 489 isset($index_tag_archives) && $index_tag_archives == 'true' ? 'checked' : ''
381 490 );
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>');
491 + echo '<span class="description"><strong>Tag Archives:</strong> When checked, prevents search engines from indexing tag archive pages (e.g., /tag/technology/). Useful if you have many low-quality tag pages or want to focus on category-based organization instead.</span>';
383 492 },
384 493 $page_slug . '_seo-controls',
385 494 $SECTION_SEO_CONTROLS
386 495 );
@@ -390,12 +499,12 @@
390 499 'Disallow Author Archives',
391 500 function() use ($option_key) {
392 501 $index_author_archives = Metasync::get_option('seo_controls')['index_author_archives'] ?? 'false';
393 502 printf(
394 - '<input type="checkbox" id="index_author_archives" name="' . $option_key . '[seo_controls][index_author_archives]" value="true" %s />',
503 + '<input type="checkbox" id="index_author_archives" name="' . esc_attr($option_key) . '[seo_controls][index_author_archives]" value="true" %s />',
395 504 isset($index_author_archives) && $index_author_archives == 'true' ? 'checked' : ''
396 505 );
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>');
506 + echo '<span class="description"><strong>Author Archives:</strong> When checked, prevents search engines from indexing author archive pages (e.g., /author/john-doe/). Recommended for single-author sites or when author pages don\'t provide unique value. Multi-author sites may want to keep this unchecked.</span>';
398 507 },
399 508 $page_slug . '_seo-controls',
400 509 $SECTION_SEO_CONTROLS
401 510 );
@@ -405,12 +514,12 @@
405 514 'Disallow Format Archives',
406 515 function() use ($option_key) {
407 516 $index_format_archives = Metasync::get_option('seo_controls')['index_format_archives'] ?? 'false';
408 517 printf(
409 - '<input type="checkbox" id="index_format_archives" name="' . $option_key . '[seo_controls][index_format_archives]" value="true" %s />',
518 + '<input type="checkbox" id="index_format_archives" name="' . esc_attr($option_key) . '[seo_controls][index_format_archives]" value="true" %s />',
410 519 isset($index_format_archives) && $index_format_archives == 'true' ? 'checked' : ''
411 520 );
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>');
521 + echo '<span class="description"><strong>Format Archives:</strong> When checked, prevents search engines from indexing post format archive pages (e.g., /type/aside/, /type/gallery/). These are rarely useful for SEO and can create duplicate content issues. Recommended for most sites.</span>';
413 522 },
414 523 $page_slug . '_seo-controls',
415 524 $SECTION_SEO_CONTROLS
416 525 );
@@ -420,12 +529,12 @@
420 529 'Disallow Category Archives',
421 530 function() use ($option_key) {
422 531 $index_category_archives = Metasync::get_option('seo_controls')['index_category_archives'] ?? 'false';
423 532 printf(
424 - '<input type="checkbox" id="index_category_archives" name="' . $option_key . '[seo_controls][index_category_archives]" value="true" %s />',
533 + '<input type="checkbox" id="index_category_archives" name="' . esc_attr($option_key) . '[seo_controls][index_category_archives]" value="true" %s />',
425 534 isset($index_category_archives) && $index_category_archives == 'true' ? 'checked' : ''
426 535 );
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>');
536 + echo '<span class="description"><strong>Category Archives:</strong> When checked, prevents search engines from indexing category archive pages (e.g., /category/news/). This may be useful if your category pages have thin content or if you want to consolidate SEO value on main pages instead. Use with caution as category pages can be valuable for site organization.</span>';
428 537 },
429 538 $page_slug . '_seo-controls',
430 539 $SECTION_SEO_CONTROLS
431 540 );
@@ -435,9 +544,9 @@
435 544 'Add No-follow to External Links',
436 545 function() use ($option_key) {
437 546 $add_nofollow_to_external_links = Metasync::get_option('seo_controls')['add_nofollow_to_external_links'] ?? 'false';
438 547 printf(
439 - '<input type="checkbox" id="add_nofollow_to_external_links" name="' . $option_key . '[seo_controls][add_nofollow_to_external_links]" value="true" %s />',
548 + '<input type="checkbox" id="add_nofollow_to_external_links" name="' . esc_attr($option_key) . '[seo_controls][add_nofollow_to_external_links]" value="true" %s />',
440 549 isset($add_nofollow_to_external_links) && $add_nofollow_to_external_links == 'true' ? 'checked' : ''
441 550 );
442 551 printf('<span class="description"><strong>No-follow External Links:</strong> When checked, automatically adds <code>rel="nofollow"</code> attribute to all external links appearing in posts, pages, and other post types when rendered by %s. This tells search engines not to follow these links, which can help preserve your site\'s SEO value and prevent passing link juice to external sites.</span>', esc_html(Metasync::get_whitelabel_otto_name()));
443 552 },
@@ -463,12 +572,12 @@
463 572 'Override Other Plugins\' Robots Tags',
464 573 function() use ($option_key) {
465 574 $override_robots_tags = Metasync::get_option('seo_controls')['override_robots_tags'] ?? 'false';
466 575 printf(
467 - '<input type="checkbox" id="override_robots_tags" name="' . $option_key . '[seo_controls][override_robots_tags]" value="true" %s />',
576 + '<input type="checkbox" id="override_robots_tags" name="' . esc_attr($option_key) . '[seo_controls][override_robots_tags]" value="true" %s />',
468 577 isset($override_robots_tags) && $override_robots_tags == 'true' ? 'checked' : ''
469 578 );
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>');
579 + echo '<span class="description"><strong>Override Robots Tags:</strong> When checked, ' . esc_html(Metasync_Settings_Registration::instance()->get_effective_menu_title()) . ' will take precedence over robots meta tags from other SEO plugins (Yoast, Rank Math, All in One SEO, etc.). This removes their noindex tags when you want to allow indexing on archive pages. Only enable this if other plugins are conflicting with your indexation settings.</span>';
471 580 },
472 581 $page_slug . '_seo-controls',
473 582 $SECTION_SEO_CONTROLS_ADVANCED
474 583 );
@@ -491,13 +600,13 @@
491 600 'Enable Google Instant Indexing',
492 601 function() use ($option_key) {
493 602 $enable_googleinstantindex = Metasync::get_option('seo_controls')['enable_googleinstantindex'] ?? 'false';
494 603 printf(
495 - '<input type="checkbox" id="enable_googleinstantindex" name="' . $option_key . '[seo_controls][enable_googleinstantindex]" value="true" %s />',
604 + '<input type="checkbox" id="enable_googleinstantindex" name="' . esc_attr($option_key) . '[seo_controls][enable_googleinstantindex]" value="true" %s />',
496 605 isset($enable_googleinstantindex) && $enable_googleinstantindex == 'true' ? 'checked' : ''
497 606 );
498 607 Metasync::render_tooltip_icon('enable_googleinstantindex', 'Turns on direct URL submission to Google. It only works after you add a service-account key below and verify your site in Search Console — set those up first.');
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>');
608 + echo '<span class="description" style="margin-left:8px;"><strong>Enable Instant Indexing:</strong> When checked, enables the Google Instant Indexing feature which allows you to submit URLs directly to Google for faster indexing. Configure it in the Google Instant Indexing section below.</span>';
500 609 },
501 610 $page_slug . '_seo-controls',
502 611 $SECTION_SEO_CONTROLS_INSTANT_INDEX
503 612 );
@@ -538,13 +647,13 @@
538 647 $enable_binginstantindex = 'true';
539 648 }
540 649
541 650 printf(
542 - '<input type="checkbox" id="enable_binginstantindex" name="' . $option_key . '[seo_controls][enable_binginstantindex]" value="true" %s %s />',
651 + '<input type="checkbox" id="enable_binginstantindex" name="' . esc_attr($option_key) . '[seo_controls][enable_binginstantindex]" value="true" %s %s />',
543 652 isset($enable_binginstantindex) && $enable_binginstantindex == 'true' ? 'checked' : '',
544 653 $has_api_key ? 'data-auto-enabled="true"' : ''
545 654 );
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>');
655 + echo '<span class="description"><strong>Enable Bing Instant Indexing:</strong> Automatically enabled when you configure an API key below. When enabled, this activates the IndexNow protocol to instantly notify Bing, Yandex, and other search engines about URL changes.</span>';
547 656 },
548 657 $page_slug . '_seo-controls',
549 658 'seo_controls_bing_instant_index'
550 659 );
@@ -585,9 +694,9 @@
585 694 'otto_pixel_uuid',
586 695 $whitelabel_otto_name . ' Pixel UUID',
587 696 function() use ($option_key) {
588 697 $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]"/>');
698 + echo '<input type="text" size="40" value = "'.esc_attr($value).'" name="' . esc_attr($option_key) . '[general][otto_pixel_uuid]"/>';
590 699 },
591 700 $page_slug . '_general',
592 701 $SECTION_METASYNC
593 702 );
@@ -599,12 +708,12 @@
599 708 'Disable ' . $whitelabel_otto_name . ' for Logged in Users',
600 709 function() use ($whitelabel_otto_name, $option_key) {
601 710 $otto_disable_on_loggedin = Metasync::get_option('general')['otto_disable_on_loggedin'] ?? '';
602 711 printf(
603 - '<input type="checkbox" id="otto_disable_on_loggedin" name="' . $option_key . '[general][otto_disable_on_loggedin]" value="true" %s />',
712 + '<input type="checkbox" id="otto_disable_on_loggedin" name="' . esc_attr($option_key) . '[general][otto_disable_on_loggedin]" value="true" %s />',
604 713 isset($otto_disable_on_loggedin) && $otto_disable_on_loggedin == 'true' ? 'checked' : ''
605 714 );
606 - printf('<span class="description"> This disables '.$whitelabel_otto_name.' when logged in to allow editing original page contents</span>');
715 + echo '<span class="description"> This disables '.esc_html($whitelabel_otto_name).' when logged in to allow editing original page contents</span>';
607 716 },
608 717 $page_slug . '_general',
609 718 $SECTION_METASYNC
610 719 );
@@ -615,9 +724,9 @@
615 724 'Disable ' . $whitelabel_otto_name . ' Frontend Toolbar',
616 725 function() use ($whitelabel_otto_name, $option_key) {
617 726 $otto_disable_toolbar = Metasync::get_option('general')['otto_disable_preview_button'] ?? false;
618 727 printf(
619 - '<input type="checkbox" id="otto_disable_preview_button" name="' . $option_key . '[general][otto_disable_preview_button]" value="true" %s />',
728 + '<input type="checkbox" id="otto_disable_preview_button" name="' . esc_attr($option_key) . '[general][otto_disable_preview_button]" value="true" %s />',
620 729 filter_var($otto_disable_toolbar, FILTER_VALIDATE_BOOLEAN) ? 'checked' : ''
621 730 );
622 731 printf('<span class="description"> Hide the entire frontend toolbar (status indicator, preview button, and debug button) on the frontend. %s functionality will still work, but the toolbar controls will be hidden.</span>', esc_html($whitelabel_otto_name));
623 732 },
@@ -624,8 +733,43 @@
624 733 $page_slug . '_general',
625 734 $SECTION_METASYNC
626 735 );
627 736
737 + # Global SEO title & description priority.
738 + #
739 + # Unchecked (the default) is the behaviour every site already runs: a
740 + # value the customer typed in the sidebar outranks OTTO's suggestion.
741 + # Checked hands precedence to OTTO, which is what makes a successful
742 + # OTTO deployment visible on a page that also holds a custom value.
743 + #
744 + # This only re-orders what RENDERS. Nothing is written to or deleted
745 + # from the stored post meta, so unchecking it restores the custom value
746 + # untouched. Applies to the SEO title and meta description only.
747 + add_settings_field(
748 + 'seo_priority',
749 + 'SEO Title & Description Priority',
750 + function() use ($whitelabel_otto_name, $option_key) {
751 + $seo_priority = Metasync::get_option('general')['seo_priority'] ?? '';
752 + $checked = $seo_priority === Metasync_Seo_Precedence::PRIORITY_OTTO ? 'checked' : '';
753 + $name = $option_key . '[general][seo_priority]';
754 +
755 + echo '<div class="metasync-seo-priority-control">';
756 + printf(
757 + '<label for="seo_priority"><input type="checkbox" id="seo_priority" name="%s" value="otto" %s /> <strong>Prioritize approved %s values</strong></label>',
758 + esc_attr($name),
759 + esc_attr($checked),
760 + esc_html($whitelabel_otto_name)
761 + );
762 + printf(
763 + '<p class="description" id="seo_priority_description"><strong>Off:</strong> Your custom title and description appear first; %1$s fills in only where you have no value.<br><strong>On:</strong> Approved %1$s values appear first; your custom values remain the fallback.<br><span class="metasync-seo-priority-note">Your saved values are never deleted or overwritten.</span></p>',
764 + esc_html($whitelabel_otto_name)
765 + );
766 + echo '</div>';
767 + },
768 + $page_slug . '_general',
769 + $SECTION_METASYNC
770 + );
771 +
628 772 # WP Rocket compatibility mode setting
629 773 add_settings_field(
630 774 'otto_wp_rocket_compat',
631 775 'WP Rocket Compatibility',
@@ -632,9 +776,9 @@
632 776 function() use ($whitelabel_otto_name, $option_key) {
633 777 $value = Metasync::get_option('general')['otto_wp_rocket_compat'] ?? 'auto';
634 778 $wp_rocket_active = class_exists('WP_Rocket');
635 779
636 - echo '<select id="otto_wp_rocket_compat" name="' . $option_key . '[general][otto_wp_rocket_compat]">';
780 + echo '<select id="otto_wp_rocket_compat" name="' . esc_attr($option_key) . '[general][otto_wp_rocket_compat]">';
637 781 echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
638 782 echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>';
639 783 echo '<option value="http"' . selected($value, 'http', false) . '>HTTP Mode (Safer)</option>';
640 784 echo '<option value="disable_otto"' . selected($value, 'disable_otto', false) . '>Disable ' . esc_html($whitelabel_otto_name) . ' when WP Rocket is active</option>';
@@ -662,9 +806,9 @@
662 806 function() use ($option_key) {
663 807 $value = Metasync::get_option('general')['otto_sg_optimizer_compat'] ?? 'auto';
664 808 $sg_optimizer_active = defined('SiteGround_Optimizer\VERSION') || class_exists('SiteGround_Optimizer\Parser\Parser');
665 809
666 - echo '<select id="otto_sg_optimizer_compat" name="' . $option_key . '[general][otto_sg_optimizer_compat]">';
810 + echo '<select id="otto_sg_optimizer_compat" name="' . esc_attr($option_key) . '[general][otto_sg_optimizer_compat]">';
667 811 echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
668 812 echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>';
669 813 echo '<option value="http"' . selected($value, 'http', false) . '>HTTP Mode (Safer)</option>';
670 814 echo '</select>';
@@ -694,9 +838,9 @@
694 838 'BookingPress Session Compatibility',
695 839 function() use ($option_key) {
696 840 $value = Metasync::get_option('general')['bookingpress_session_compat'] ?? 'auto';
697 841
698 - echo '<select id="bookingpress_session_compat" name="' . $option_key . '[general][bookingpress_session_compat]">';
842 + echo '<select id="bookingpress_session_compat" name="' . esc_attr($option_key) . '[general][bookingpress_session_compat]">';
699 843 echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
700 844 echo '<option value="off"' . selected($value, 'off', false) . '>Disabled</option>';
701 845 echo '</select>';
702 846
@@ -724,9 +868,9 @@
724 868 'Disable ' . $whitelabel_otto_name . ' for Bot Traffic',
725 869 function() use ($whitelabel_otto_name, $option_key) {
726 870 $otto_disable_for_bots = Metasync::get_option('general')['otto_disable_for_bots'] ?? false;
727 871 printf(
728 - '<input type="checkbox" id="otto_disable_for_bots" name="' . $option_key . '[general][otto_disable_for_bots]" value="true" %s />',
872 + '<input type="checkbox" id="otto_disable_for_bots" name="' . esc_attr($option_key) . '[general][otto_disable_for_bots]" value="true" %s />',
729 873 filter_var($otto_disable_for_bots, FILTER_VALIDATE_BOOLEAN) ? 'checked' : ''
730 874 );
731 875 printf('<span class="description"> Skip %s processing for detected bots (search engines, crawlers, SEO tools) to reduce unnecessary API calls. View bot statistics in the Bot Statistics page under the SEO dropdown.</span>', esc_html($whitelabel_otto_name));
732 876 },
@@ -740,9 +884,9 @@
740 884 'Bot Whitelist',
741 885 function() use ($whitelabel_otto_name, $option_key) {
742 886 $otto_bot_whitelist = Metasync::get_option('general')['otto_bot_whitelist'] ?? '';
743 887 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>',
888 + '<textarea id="otto_bot_whitelist" name="' . esc_attr($option_key) . '[general][otto_bot_whitelist]" rows="5" cols="50" style="width: 100%%; max-width: 500px; font-family: monospace;">%s</textarea>',
745 889 esc_textarea($otto_bot_whitelist)
746 890 );
747 891 echo '<p class="description">';
748 892 echo 'Enter bot names or user-agent patterns (one per line) that should <strong>always</strong> be processed by ' . esc_html($whitelabel_otto_name) . ', even when "Disable for Bots" is enabled.<br>';
@@ -761,9 +905,9 @@
761 905 'Bot Blacklist',
762 906 function() use ($whitelabel_otto_name, $option_key) {
763 907 $otto_bot_blacklist = Metasync::get_option('general')['otto_bot_blacklist'] ?? '';
764 908 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>',
909 + '<textarea id="otto_bot_blacklist" name="' . esc_attr($option_key) . '[general][otto_bot_blacklist]" rows="5" cols="50" style="width: 100%%; max-width: 500px; font-family: monospace;">%s</textarea>',
766 910 esc_textarea($otto_bot_blacklist)
767 911 );
768 912 echo '<p class="description">';
769 913 echo 'Enter bot names or user-agent patterns (one per line) that should <strong>always</strong> be blocked from ' . esc_html($whitelabel_otto_name) . ' processing, regardless of other settings.<br>';
@@ -785,9 +929,9 @@
785 929 printf(
786 930 '<a href="%s" class="button button-secondary"><span class="dashicons dashicons-chart-bar" style="margin-top:3px;font-size:15px;width:15px;height:15px;"></span> View Bot Statistics</a>',
787 931 esc_url(admin_url('admin.php?page=' . $page_slug . '-bot-statistics'))
788 932 );
789 - printf('<p class="description">View detailed bot detection statistics, breakdown by bot type, and unique bot entries with hit counts.</p>');
933 + echo '<p class="description">View detailed bot detection statistics, breakdown by bot type, and unique bot entries with hit counts.</p>';
790 934 },
791 935 $page_slug . '_general',
792 936 $SECTION_METASYNC
793 937 );
@@ -798,22 +942,20 @@
798 942 'periodic_clear_ottopage_cache',
799 943 'Clear Page Cache',
800 944 function() use ($option_key) {
801 945 $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>');
946 + echo '<select style = "width : 250px" name="' . esc_attr($option_key) . '[general][periodic_clear_ottopage_cache]" id="heading_style">';
947 + echo '<option value="24" '. selected($periodic_clear_ottopage_cache, '24', false) . '>Clear Daily</option>';
948 + echo '<option value="36" '. selected($periodic_clear_ottopage_cache, '36', false) . '>Clear Every 2 days</option>';
949 + echo '<option value="40" '. selected($periodic_clear_ottopage_cache, '40', false) . '>Clear Weekly</option>';
950 + echo '<option value="0"'.selected($periodic_clear_ottopage_cache, '0', false).'>Clear Monthly (Default)</option>';
951 + echo '</select>';
808 952
809 953 # get last cleared timestamp
810 954 $timestamp = get_option('metasync_refresh_all_caches')['pages'] ?? false;
811 955
812 956 if($timestamp > 0){
813 - printf(
814 - '<p class="descriptionValue">last Cleared : '.date('y-m-d H:i:s', $timestamp).'</p>'
815 - );
957 + echo '<p class="descriptionValue">last Cleared : '.esc_html(gmdate('y-m-d H:i:s', $timestamp)).'</p>';
816 958 }
817 959
818 960 },
819 961 $page_slug . '_otto_cache',
@@ -824,22 +966,20 @@
824 966 'periodic_clear_ottopost_cache',
825 967 'Clear Post Cache',
826 968 function() use ($option_key) {
827 969 $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>');
970 + echo '<select style = "width : 250px" name="' . esc_attr($option_key) . '[general][periodic_clear_ottopost_cache]" id="heading_style">';
971 + echo '<option value="24" '. selected($periodic_clear_ottopost_cache, '24', false) . '>Clear Daily</option>';
972 + echo '<option value="36" '. selected($periodic_clear_ottopost_cache, '36', false) . '>Clear Every 2 days</option>';
973 + echo '<option value="40" '. selected($periodic_clear_ottopost_cache, '40', false) . '>Clear Weekly</option>';
974 + echo '<option value="0"'.selected($periodic_clear_ottopost_cache, '0', false).'>Clear Monthly (Default)</option>';
975 + echo '</select>';
834 976
835 977 # get last cleared timestamp
836 978 $timestamp = get_option('metasync_refresh_all_caches')['posts'] ?? false;
837 979
838 980 if($timestamp > 0){
839 - printf(
840 - '<p class="descriptionValue">last Cleared : '.date('y-m-d H:i:s', $timestamp).'</p>'
841 - );
981 + echo '<p class="descriptionValue">last Cleared : '.esc_html(gmdate('y-m-d H:i:s', $timestamp)).'</p>';
842 982 }
843 983 },
844 984 $page_slug . '_otto_cache',
845 985 $SECTION_METASYNC
@@ -849,22 +989,20 @@
849 989 'periodic_clear_otto_cache',
850 990 'Clear all cache',
851 991 function() use ($option_key) {
852 992 $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>');
993 + echo '<select style = "width : 250px" name="' . esc_attr($option_key) . '[general][periodic_clear_otto_cache]" id="heading_style">';
994 + echo '<option value="24" '. selected($periodic_clear_otto_cache, '24', false) . '>Clear Daily</option>';
995 + echo '<option value="36" '. selected($periodic_clear_otto_cache, '36', false) . '>Clear Every 2 days</option>';
996 + echo '<option value="40" '. selected($periodic_clear_otto_cache, '40', false) . '>Clear Weekly</option>';
997 + echo '<option value="0"'.selected($periodic_clear_otto_cache, '0', false).'>Clear Monthly (Default)</option>';
998 + echo '</select>';
859 999
860 1000 # get last cleared timestamp
861 1001 $timestamp = get_option('metasync_refresh_all_caches')['general'] ?? false;
862 1002
863 1003 if($timestamp > 0){
864 - printf(
865 - '<p class="descriptionValue">last Cleared : '.date('y-m-d H:i:s', $timestamp).'</p>'
866 - );
1004 + echo '<p class="descriptionValue">last Cleared : '.esc_html(gmdate('y-m-d H:i:s', $timestamp)).'</p>';
867 1005 }
868 1006
869 1007 },
870 1008 $page_slug . '_otto_cache',
@@ -879,12 +1017,12 @@
879 1017 'Disable Common Robots Meta Box',
880 1018 function() use ($option_key) {
881 1019 $disabled = Metasync::get_option('general')['disable_common_robots_metabox'] ?? false;
882 1020 printf(
883 - '<input type="checkbox" id="disable_common_robots_metabox" name="' . $option_key . '[general][disable_common_robots_metabox]" value="1" %s />',
1021 + '<input type="checkbox" id="disable_common_robots_metabox" name="' . esc_attr($option_key) . '[general][disable_common_robots_metabox]" value="1" %s />',
884 1022 $disabled ? 'checked' : ''
885 1023 );
886 - printf('<span class="description"> Hide the Common Robots Meta box on post/page edit screens</span>');
1024 + printf('<span class="description"> Hide the Common Robots meta box and stop %1$s emitting index/follow robots directives on the front end. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
887 1025 },
888 1026 $page_slug . '_general',
889 1027 $SECTION_METASYNC
890 1028 );
@@ -894,12 +1032,12 @@
894 1032 'Disable Advance Robots Meta Box',
895 1033 function() use ($option_key) {
896 1034 $disabled = Metasync::get_option('general')['disable_advance_robots_metabox'] ?? false;
897 1035 printf(
898 - '<input type="checkbox" id="disable_advance_robots_metabox" name="' . $option_key . '[general][disable_advance_robots_metabox]" value="1" %s />',
1036 + '<input type="checkbox" id="disable_advance_robots_metabox" name="' . esc_attr($option_key) . '[general][disable_advance_robots_metabox]" value="1" %s />',
899 1037 $disabled ? 'checked' : ''
900 1038 );
901 - printf('<span class="description"> Hide the Advance Robots Meta box on post/page edit screens</span>');
1039 + printf('<span class="description"> Hide the Advance Robots meta box and stop %1$s emitting max-snippet, max-image-preview and max-video-preview directives. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
902 1040 },
903 1041 $page_slug . '_general',
904 1042 $SECTION_METASYNC
905 1043 );
@@ -909,12 +1047,12 @@
909 1047 'Disable Redirection Meta Box',
910 1048 function() use ($option_key) {
911 1049 $disabled = Metasync::get_option('general')['disable_redirection_metabox'] ?? false;
912 1050 printf(
913 - '<input type="checkbox" id="disable_redirection_metabox" name="' . $option_key . '[general][disable_redirection_metabox]" value="1" %s />',
1051 + '<input type="checkbox" id="disable_redirection_metabox" name="' . esc_attr($option_key) . '[general][disable_redirection_metabox]" value="1" %s />',
914 1052 $disabled ? 'checked' : ''
915 1053 );
916 - printf('<span class="description"> Hide the Redirection meta box on post/page edit screens</span>');
1054 + echo '<span class="description"> Hide the Redirection meta box and stop redirects created by it from running. Rules added on the Redirections screen are unaffected. Saved redirects are kept.</span>';
917 1055 },
918 1056 $page_slug . '_general',
919 1057 $SECTION_METASYNC
920 1058 );
@@ -924,12 +1062,12 @@
924 1062 'Disable Canonical Meta Box',
925 1063 function() use ($option_key) {
926 1064 $disabled = Metasync::get_option('general')['disable_canonical_metabox'] ?? false;
927 1065 printf(
928 - '<input type="checkbox" id="disable_canonical_metabox" name="' . $option_key . '[general][disable_canonical_metabox]" value="1" %s />',
1066 + '<input type="checkbox" id="disable_canonical_metabox" name="' . esc_attr($option_key) . '[general][disable_canonical_metabox]" value="1" %s />',
929 1067 $disabled ? 'checked' : ''
930 1068 );
931 - printf('<span class="description"> Hide the Canonical meta box on post/page edit screens</span>');
1069 + printf('<span class="description"> Hide the Canonical meta box and stop %1$s emitting or overriding the canonical URL. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
932 1070 },
933 1071 $page_slug . '_general',
934 1072 $SECTION_METASYNC
935 1073 );
@@ -939,12 +1077,12 @@
939 1077 'Disable Social Media & Open Graph Meta Box',
940 1078 function() use ($option_key) {
941 1079 $disabled = Metasync::get_option('general')['disable_social_opengraph_metabox'] ?? false;
942 1080 printf(
943 - '<input type="checkbox" id="disable_social_opengraph_metabox" name="' . $option_key . '[general][disable_social_opengraph_metabox]" value="1" %s />',
1081 + '<input type="checkbox" id="disable_social_opengraph_metabox" name="' . esc_attr($option_key) . '[general][disable_social_opengraph_metabox]" value="1" %s />',
944 1082 $disabled ? 'checked' : ''
945 1083 );
946 - printf('<span class="description"> Hide the Social Media & Open Graph meta box on post/page edit screens</span>');
1084 + printf('<span class="description"> Hide the Social Media &amp; Open Graph meta box and stop %1$s emitting Open Graph and Twitter tags. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
947 1085 },
948 1086 $page_slug . '_general',
949 1087 $SECTION_METASYNC
950 1088 );
@@ -954,12 +1092,12 @@
954 1092 'Disable Schema Markup Meta Box',
955 1093 function() use ($option_key) {
956 1094 $disabled = Metasync::get_option('general')['disable_schema_markup_metabox'] ?? false;
957 1095 printf(
958 - '<input type="checkbox" id="disable_schema_markup_metabox" name="' . $option_key . '[general][disable_schema_markup_metabox]" value="1" %s />',
1096 + '<input type="checkbox" id="disable_schema_markup_metabox" name="' . esc_attr($option_key) . '[general][disable_schema_markup_metabox]" value="1" %s />',
959 1097 $disabled ? 'checked' : ''
960 1098 );
961 - printf('<span class="description"> Hide the Schema Markup meta box on post/page edit screens</span>');
1099 + printf('<span class="description"> Hide the Schema Markup meta box and stop %1$s emitting any JSON-LD, including site-wide Local SEO markup. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
962 1100 },
963 1101 $page_slug . '_general',
964 1102 $SECTION_METASYNC
965 1103 );
@@ -964,17 +1102,32 @@
964 1102 $SECTION_METASYNC
965 1103 );
966 1104
967 1105 add_settings_field(
1106 + 'disable_language_alternates_metabox',
1107 + 'Disable Language Alternates Meta Box',
1108 + function() use ($option_key) {
1109 + $disabled = Metasync::get_option('general')['disable_language_alternates_metabox'] ?? false;
1110 + printf(
1111 + '<input type="checkbox" id="disable_language_alternates_metabox" name="' . esc_attr($option_key) . '[general][disable_language_alternates_metabox]" value="1" %s />',
1112 + $disabled ? 'checked' : ''
1113 + );
1114 + printf('<span class="description"> Hide the Language Alternates (hreflang) panel in the block editor and stop %1$s emitting any %2$slink rel=&quot;alternate&quot; hreflang%3$s tags on the front end, including the auto-detected WPML entries. While disabled, %1$s does not manage or override this data — another SEO plugin, or WPML itself, is free to handle it. Saved values are kept and restored if you re-enable this.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')), '&lt;', '&gt;');
1115 + },
1116 + $page_slug . '_general',
1117 + $SECTION_METASYNC
1118 + );
1119 +
1120 + add_settings_field(
968 1121 'disable_seo_metabox',
969 1122 'Disable SEO Meta Box',
970 1123 function() use ($option_key) {
971 1124 $disabled = Metasync::get_option('general')['disable_seo_metabox'] ?? false;
972 1125 printf(
973 - '<input type="checkbox" id="disable_seo_metabox" name="' . $option_key . '[general][disable_seo_metabox]" value="1" %s />',
1126 + '<input type="checkbox" id="disable_seo_metabox" name="' . esc_attr($option_key) . '[general][disable_seo_metabox]" value="1" %s />',
974 1127 $disabled ? 'checked' : ''
975 1128 );
976 - printf('<span class="description"> Hide the SEO Title & Meta Description meta box on post/page edit screens</span>');
1129 + echo '<span class="description"> Hide the SEO Title & Meta Description meta box on post/page edit screens</span>';
977 1130 },
978 1131 $page_slug . '_general',
979 1132 $SECTION_METASYNC
980 1133 );
@@ -984,9 +1137,9 @@
984 1137 'Open External Links in New Tab/Window',
985 1138 function() use ($option_key) {
986 1139 $enabled = Metasync::get_option('general')['open_external_links'] ?? false;
987 1140 printf(
988 - '<input type="checkbox" id="open_external_links" name="' . $option_key . '[general][open_external_links]" value="1" %s />',
1141 + '<input type="checkbox" id="open_external_links" name="' . esc_attr($option_key) . '[general][open_external_links]" value="1" %s />',
989 1142 $enabled ? 'checked' : ''
990 1143 );
991 1144 printf('<span class="description"> Automatically add <code>target="_blank"</code> attribute to external links appearing in your posts, pages, and other post types when rendered by %s. The attribute is applied when the url is displayed.</span>', esc_html(Metasync::get_whitelabel_otto_name()));
992 1145 },
@@ -1002,11 +1155,11 @@
1002 1155 $current_permalink_structure = get_option('permalink_structure');
1003 1156 $current_rewrite_rules = get_option('rewrite_rules');
1004 1157 // Check if the current permalink structure is set to "Plain"
1005 1158 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>');
1159 + echo '<span class="description" style="color:#ff0000;opacity:1;">To ensure compatibility, Please Update your Permalink structure to any option other than "plain. For any Inquiries contact support <a href="' . esc_url(get_admin_url() . 'options-permalink.php') . '">Check Setting</a> </span>';
1007 1160 } else {
1008 - printf('<span class="description" style="color:#008000;opacity:1;">Permalink is Okay </span>');
1161 + echo '<span class="description" style="color:#008000;opacity:1;">Permalink is Okay </span>';
1009 1162 }
1010 1163 },
1011 1164 $page_slug . '_general',
1012 1165 $SECTION_METASYNC
@@ -1018,9 +1171,9 @@
1018 1171 'Hide Dashboard',
1019 1172 function() use ($option_key) {
1020 1173 $hide_dashboard = Metasync::get_option('general')['hide_dashboard_framework'] ?? '';
1021 1174 printf(
1022 - '<input type="checkbox" id="hide_dashboard_framework" name="' . $option_key . '[general][hide_dashboard_framework]" value="true" %s />',
1175 + '<input type="checkbox" id="hide_dashboard_framework" name="' . esc_attr($option_key) . '[general][hide_dashboard_framework]" value="true" %s />',
1023 1176 isset($hide_dashboard) && $hide_dashboard == 'true' ? 'checked' : ''
1024 1177 );
1025 1178 printf('<span class="description"> Hide the %s dashboard</span>', esc_html(Metasync_Settings_Registration::instance()->get_effective_menu_title()));
1026 1179 },
@@ -1036,9 +1189,9 @@
1036 1189 'Show ' . $this->get_effective_menu_title() . ' Status in Admin Bar',
1037 1190 function() use ($option_key) {
1038 1191 $show_admin_bar = Metasync::get_option('general')['show_admin_bar_status'] ?? true;
1039 1192 printf(
1040 - '<input type="checkbox" id="show_admin_bar_status" name="' . $option_key . '[general][show_admin_bar_status]" value="true" %s />',
1193 + '<input type="checkbox" id="show_admin_bar_status" name="' . esc_attr($option_key) . '[general][show_admin_bar_status]" value="true" %s />',
1041 1194 $show_admin_bar ? 'checked' : ''
1042 1195 );
1043 1196 printf('<span class="description">Show the %s status indicator in the WordPress admin bar.</span>', esc_html(Metasync::get_effective_plugin_name()));
1044 1197 },
@@ -1045,24 +1198,8 @@
1045 1198 $page_slug . '_general',
1046 1199 $SECTION_METASYNC
1047 1200 );
1048 1201
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 1202 # Add User Role Sync Setting
1066 1203 add_settings_field(
1067 1204 'content_genius_sync_roles',
1068 1205 'Content Genius User Roles to Sync',
@@ -1164,9 +1301,9 @@
1164 1301 echo '<select id="default_page_builder" name="' . esc_attr($option_key) . '[general][default_page_builder]" style="min-width: 300px;">';
1165 1302
1166 1303 foreach ($builders as $key => $builder) {
1167 1304 $is_detected = $builder['detected'];
1168 - $label = esc_html($builder['label']);
1305 + $label = $builder['label'];
1169 1306 if ($is_detected && $key !== 'gutenberg') {
1170 1307 $label .= ' — Detected';
1171 1308 } elseif (!$is_detected) {
1172 1309 $label .= ' — Not Detected';
@@ -1178,9 +1315,9 @@
1178 1315 '<option value="%s" %s%s>%s</option>',
1179 1316 esc_attr($key),
1180 1317 selected(empty($current) ? 'gutenberg' : $current, $key, false),
1181 1318 $is_detected ? '' : ' disabled',
1182 - $label
1319 + esc_html($label)
1183 1320 );
1184 1321 }
1185 1322
1186 1323 echo '</select>';
@@ -1229,9 +1366,9 @@
1229 1366 printf(
1230 1367 '<a href="%s" class="button button-secondary"><span class="dashicons dashicons-download" style="margin-top:3px;font-size:15px;width:15px;height:15px;"></span> Import from SEO Plugins</a>',
1231 1368 esc_url(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-import-external'))
1232 1369 );
1233 - printf('<p class="description">Import settings and data from other SEO plugins (Yoast, Rank Math, AIOSEO, etc).</p>');
1370 + echo '<p class="description">Import settings and data from other SEO plugins (Yoast, Rank Math, AIOSEO, etc).</p>';
1234 1371 },
1235 1372 $page_slug . '_general',
1236 1373 $SECTION_METASYNC
1237 1374 );
@@ -1246,10 +1383,10 @@
1246 1383 'white_label_plugin_name',
1247 1384 'Plugin Name' . Metasync::get_tooltip_icon_html('wl_plugin_name_tooltip', 'The name shown for this plugin throughout the WordPress admin — menus, page titles, and system messages. Replace it with your own brand name (max 18 characters).'),
1248 1385 function() use ($option_key) {
1249 1386 $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>');
1387 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_name]" value="' . esc_attr($value) . '" maxlength="18" />';
1388 + echo '<p class="description">This name will be used for general plugin branding (WordPress menus, page titles, and system messages). Maximum 18 characters.</p>';
1252 1389 },
1253 1390 $page_slug . '_branding',
1254 1391 $SECTION_METASYNC
1255 1392 );
@@ -1258,9 +1395,9 @@
1258 1395 'whitelabel_otto_name',
1259 1396 'OTTO Name' . Metasync::get_tooltip_icon_html('wl_otto_name_tooltip', 'The name used wherever OTTO is mentioned in the plugin (e.g. "Enable [Name] Server Side Rendering"). Rename it to match your own product branding.'),
1260 1397 function() use ($option_key) {
1261 1398 $value = Metasync::get_option('general')['whitelabel_otto_name'] ?? '';
1262 - printf('<input type="text" name="' . $option_key . '[general][whitelabel_otto_name]" value="' . esc_attr($value) . '" />');
1399 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][whitelabel_otto_name]" value="' . esc_attr($value) . '" />';
1263 1400 $example_name = !empty($value) ? $value : 'OTTO';
1264 1401 printf('<p class="description">This name will be used for OTTO feature references (e.g., "Enable %s Server Side Rendering").</p>', esc_html($example_name));
1265 1402 },
1266 1403 $page_slug . '_branding',
@@ -1272,9 +1409,9 @@
1272 1409 'Logo (Light Theme)' . Metasync::get_tooltip_icon_html('wl_logo_light_tooltip', 'Your logo shown in the admin dashboard when it\'s set to light mode. Leave blank to use the default logo.'),
1273 1410 function() use ($option_key) {
1274 1411 $whitelabel_settings = Metasync::get_whitelabel_settings();
1275 1412 $value = $whitelabel_settings['logo_light'] ?? '';
1276 - printf('<input type="url" name="' . $option_key . '[whitelabel][logo_light]" value="' . esc_attr($value) . '" size="60" />');
1413 + echo '<input type="url" name="' . esc_attr($option_key) . '[whitelabel][logo_light]" value="' . esc_attr($value) . '" size="60" />';
1277 1414 printf('<p class="description">Displayed when the admin UI is in light mode. Leave blank to use the default %s logo.</p>', esc_html(Metasync::get_effective_plugin_name()));
1278 1415 },
1279 1416 $page_slug . '_branding',
1280 1417 $SECTION_METASYNC
@@ -1285,9 +1422,9 @@
1285 1422 'Logo (Dark Theme)' . Metasync::get_tooltip_icon_html('wl_logo_dark_tooltip', 'Your logo shown in the admin dashboard when it\'s set to dark mode. Leave blank to use the default logo.'),
1286 1423 function() use ($option_key) {
1287 1424 $whitelabel_settings = Metasync::get_whitelabel_settings();
1288 1425 $value = $whitelabel_settings['logo_dark'] ?? '';
1289 - printf('<input type="url" name="' . $option_key . '[whitelabel][logo_dark]" value="' . esc_attr($value) . '" size="60" />');
1426 + echo '<input type="url" name="' . esc_attr($option_key) . '[whitelabel][logo_dark]" value="' . esc_attr($value) . '" size="60" />';
1290 1427 printf('<p class="description">Displayed when the admin UI is in dark mode. Leave blank to use the default %s logo.</p>', esc_html(Metasync::get_effective_plugin_name()));
1291 1428 },
1292 1429 $page_slug . '_branding',
1293 1430 $SECTION_METASYNC
@@ -1298,10 +1435,10 @@
1298 1435 'Dashboard URL' . Metasync::get_tooltip_icon_html('wl_dashboard_url_tooltip', 'The web address of your own branded dashboard, shown to clients instead of the Search Atlas dashboard link. This is cosmetic only — it doesn\'t change where your data is actually stored.'),
1299 1436 function() use ($option_key) {
1300 1437 $whitelabel_settings = Metasync::get_whitelabel_settings();
1301 1438 $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>');
1439 + echo '<input type="url" name="' . esc_attr($option_key) . '[whitelabel][domain]" value="' . esc_attr($value) . '" size="60" />';
1440 + echo '<p class="description">Enter your whitelabel dashboard URL (e.g., https://yourdashboard.com). Used for branding purposes.</p>';
1304 1441 },
1305 1442 $page_slug . '_branding',
1306 1443 $SECTION_METASYNC
1307 1444 );
@@ -1309,9 +1446,9 @@
1309 1446 'white_label_plugin_description',
1310 1447 'Plugin Description',
1311 1448 function() use ($option_key) {
1312 1449 $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) . '" />');
1450 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_description]" value="' . esc_attr($value) . '" />';
1314 1451 },
1315 1452 $page_slug . '_branding',
1316 1453 $SECTION_METASYNC
1317 1454 );
@@ -1320,9 +1457,9 @@
1320 1457 'white_label_plugin_author',
1321 1458 'Author',
1322 1459 function() use ($option_key) {
1323 1460 $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) . '" />');
1461 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_author]" value="' . esc_attr($value) . '" />';
1325 1462 },
1326 1463 $page_slug . '_branding',
1327 1464 $SECTION_METASYNC
1328 1465 );
@@ -1331,9 +1468,9 @@
1331 1468 'white_label_plugin_author_uri',
1332 1469 'Author URI',
1333 1470 function() use ($option_key) {
1334 1471 $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) . '" />');
1472 + # printf('<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_author_uri]" value="' . esc_attr($value) . '" />');
1336 1473 # Fixed printf usage
1337 1474 printf('<input type="text" name="%s" value="%s" />', esc_attr($option_key . '[general][white_label_plugin_author_uri]'), esc_attr($value) );
1338 1475
1339 1476 },
@@ -1344,9 +1481,9 @@
1344 1481 'white_label_plugin_uri',
1345 1482 'Plugin URI',
1346 1483 function() use ($option_key) {
1347 1484 $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) . '" />');
1485 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_uri]" value="' . esc_attr($value) . '" />';
1349 1486 },
1350 1487 $page_slug . '_branding',
1351 1488 $SECTION_METASYNC
1352 1489 );
@@ -1360,10 +1497,10 @@
1360 1497 'white_label_plugin_menu_slug',
1361 1498 'Menu Slug',
1362 1499 function() use ($option_key) {
1363 1500 $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>');
1501 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_menu_slug]" value="' . esc_attr($value) . '" placeholder="hip-advanced-seo" />';
1502 + echo '<p class="description">Short identifier for the plugin\'s admin URL &mdash; lowercase letters, numbers, and dashes only (e.g. <code>hip-advanced-seo</code>). Don\'t paste a full web address.</p>';
1366 1503 },
1367 1504 $page_slug . '_branding',
1368 1505 $SECTION_METASYNC
1369 1506 );
@@ -1371,9 +1508,9 @@
1371 1508 'white_label_plugin_menu_icon',
1372 1509 'Menu Icon',
1373 1510 function() use ($option_key) {
1374 1511 $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) . '" />');
1512 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_menu_icon]" value="' . esc_attr($value) . '" />';
1376 1513 },
1377 1514 $page_slug . '_branding',
1378 1515 $SECTION_METASYNC
1379 1516 );
@@ -1388,22 +1525,22 @@
1388 1525
1389 1526 // Output radio button for Default Style.css active
1390 1527
1391 1528 printf(
1392 - '<input type="radio" id="enable_default" name="' . $option_key . '[general][enabled_plugin_css]" value="default" %s />',
1529 + '<input type="radio" id="enable_default" name="' . esc_attr($option_key) . '[general][enabled_plugin_css]" value="default" %s />',
1393 1530 ($enabled_plugin_css == 'default'||$enabled_plugin_css =='') ? 'checked' : ''
1394 1531 );
1395 - printf('<label for="enable_default">Default</label><br>');
1532 + echo '<label for="enable_default">Default</label><br>';
1396 1533
1397 1534
1398 1535 // Output radio button for Metasync Style
1399 1536 printf(
1400 - '<input type="radio" id="enable_metasync" name="' . $option_key . '[general][enabled_plugin_css]" value="metasync" %s />',
1537 + '<input type="radio" id="enable_metasync" name="' . esc_attr($option_key) . '[general][enabled_plugin_css]" value="metasync" %s />',
1401 1538 ($enabled_plugin_css == 'metasync') ? 'checked' : ''
1402 1539 );
1403 - printf('<label for="enable_metasync">Metasync Style</label>');
1540 + echo '<label for="enable_metasync">Metasync Style</label>';
1404 1541
1405 - printf('<p class="description"> Choose the default page Style Sheet: Default or MetaSync.</p>');
1542 + echo '<p class="description"> Choose the default page Style Sheet: Default or MetaSync.</p>';
1406 1543 },
1407 1544 $page_slug . '_branding',
1408 1545 $SECTION_METASYNC
1409 1546 );
@@ -1413,12 +1550,12 @@
1413 1550 'enabled_elementor_plugin_css',
1414 1551 'Choose Elementor Font Style',
1415 1552 function() use ($option_key) {
1416 1553 $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>');
1554 + echo '<select name="' . esc_attr($option_key) . '[general][enabled_elementor_plugin_css]" id="heading_style">';
1555 + echo '<option value="default"'.selected($enabled_elementor_plugin_css, 'default', false).'>Default</option>';
1556 + echo '<option value="custom" '. selected($enabled_elementor_plugin_css, 'custom', false) . '>Custom</option>';
1557 + echo '</select>';
1421 1558 },
1422 1559 $page_slug . '_branding',
1423 1560 $SECTION_METASYNC
1424 1561 );
@@ -1426,9 +1563,9 @@
1426 1563 'enabled_elementor_plugin_css_color',
1427 1564 'Choose Elementor Font Color',
1428 1565 function() use ($option_key) {
1429 1566 $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.'">');
1567 + echo '<input type="color" id="elementor_default_color_metasync" name="' . esc_attr($option_key) . '[general][enabled_elementor_plugin_css_color]" value="'.esc_attr($enabled_elementor_plugin_css_color).'">';
1431 1568 },
1432 1569 $page_slug . '_branding',
1433 1570 $SECTION_METASYNC
1434 1571 );
@@ -1439,10 +1576,10 @@
1439 1576 function() use ($option_key) {
1440 1577 // Decrypted for display — this field only renders after the
1441 1578 // whitelabel password gate has been passed.
1442 1579 $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>');
1580 + echo '<input type="password" name="' . esc_attr($option_key) . '[whitelabel][settings_password]" value="' . esc_attr($value) . '" size="30" autocomplete="new-password" />';
1581 + echo '<p class="description">Set a custom password to protect the branding settings section.</p>';
1445 1582 },
1446 1583 $page_slug . '_branding',
1447 1584 $SECTION_METASYNC
1448 1585 );
@@ -1453,10 +1590,10 @@
1453 1590 function() use ($option_key) {
1454 1591 $whitelabel_settings = Metasync::get_whitelabel_settings();
1455 1592 $value = $whitelabel_settings['recovery_email'] ?? '';
1456 1593 $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>');
1594 + printf('<input type="email" name="' . esc_attr($option_key) . '[whitelabel][recovery_email]" value="' . esc_attr($value) . '" size="30" autocomplete="email" %s />', $has_password ? 'required' : '');
1595 + echo '<p class="description">Email address to receive password recovery. <strong>Required when password is set.</strong></p>';
1459 1596 },
1460 1597 $page_slug . '_branding',
1461 1598 $SECTION_METASYNC
1462 1599 );
@@ -1486,11 +1623,11 @@
1486 1623 <td><input type="text" name="%s[whitelabel][quick_links][%d][label]" value="%s" placeholder="Link label" style="width:100%%" /></td>
1487 1624 <td><input type="url" name="%s[whitelabel][quick_links][%d][url]" value="%s" placeholder="https://…" style="width:100%%" /></td>
1488 1625 <td style="text-align:center"><input type="checkbox" name="%s[whitelabel][quick_links][%d][external]" value="1" %s /></td>
1489 1626 </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)
1627 + esc_attr($option_key), esc_attr($i), esc_attr($label),
1628 + esc_attr($option_key), esc_attr($i), esc_attr($url),
1629 + esc_attr($option_key), esc_attr($i), checked($external, true, false)
1493 1630 );
1494 1631 }
1495 1632 echo '</tbody></table>';
1496 1633 },
@@ -1517,9 +1654,9 @@
1517 1654 'Organization Name',
1518 1655 function() use ($option_key) {
1519 1656 $schema = Metasync::get_option('schema') ?? [];
1520 1657 $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));
1658 + printf('<input type="text" name="' . esc_attr($option_key) . '[schema][org_name]" value="%s" size="40" placeholder="Your organization name" />', esc_attr($value));
1522 1659 },
1523 1660 $schema_page,
1524 1661 'metasync_schema_org'
1525 1662 );
@@ -1529,9 +1666,9 @@
1529 1666 'Organization URL',
1530 1667 function() use ($option_key) {
1531 1668 $schema = Metasync::get_option('schema') ?? [];
1532 1669 $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));
1670 + printf('<input type="url" name="' . esc_attr($option_key) . '[schema][org_url]" value="%s" size="40" placeholder="https://example.com" />', esc_attr($value));
1534 1671 },
1535 1672 $schema_page,
1536 1673 'metasync_schema_org'
1537 1674 );
@@ -1541,9 +1678,9 @@
1541 1678 'Organization Logo URL' . Metasync::get_tooltip_icon_html('schema_org_logo_tooltip', 'A direct link to your logo image. Google uses this in your Organization knowledge panel — use a clear, square-ish image on a plain background, ideally at least 112x112 pixels.'),
1542 1679 function() use ($option_key) {
1543 1680 $schema = Metasync::get_option('schema') ?? [];
1544 1681 $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));
1682 + printf('<input type="url" name="' . esc_attr($option_key) . '[schema][org_logo]" value="%s" size="40" placeholder="https://example.com/logo.png" />', esc_attr($value));
1546 1683 },
1547 1684 $schema_page,
1548 1685 'metasync_schema_org'
1549 1686 );
@@ -1553,9 +1690,9 @@
1553 1690 'Contact Telephone',
1554 1691 function() use ($option_key) {
1555 1692 $schema = Metasync::get_option('schema') ?? [];
1556 1693 $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));
1694 + printf('<input type="text" name="' . esc_attr($option_key) . '[schema][org_contact_telephone]" value="%s" size="30" placeholder="+1-555-1234" />', esc_attr($value));
1558 1695 },
1559 1696 $schema_page,
1560 1697 'metasync_schema_org'
1561 1698 );
@@ -1565,9 +1702,9 @@
1565 1702 'Contact Type' . Metasync::get_tooltip_icon_html('schema_org_contact_type_tooltip', 'What this phone number is for, using schema.org wording such as "customer support", "sales", or "technical support". It tells search engines how the number should be labeled.'),
1566 1703 function() use ($option_key) {
1567 1704 $schema = Metasync::get_option('schema') ?? [];
1568 1705 $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));
1706 + printf('<input type="text" name="' . esc_attr($option_key) . '[schema][org_contact_type]" value="%s" size="30" placeholder="customer support" />', esc_attr($value));
1570 1707 },
1571 1708 $schema_page,
1572 1709 'metasync_schema_org'
1573 1710 );
@@ -1577,9 +1714,9 @@
1577 1714 'Social Profile URLs' . Metasync::get_tooltip_icon_html('schema_org_same_as_tooltip', 'Links to your official profiles on other sites (Facebook, X/Twitter, LinkedIn, Wikipedia, etc.), one per line. This is the schema.org "sameAs" property — it helps search engines confirm these accounts are really you.'),
1578 1715 function() use ($option_key) {
1579 1716 $schema = Metasync::get_option('schema') ?? [];
1580 1717 $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));
1718 + printf('<textarea name="' . esc_attr($option_key) . '[schema][org_same_as]" rows="4" cols="50" placeholder="One URL per line&#10;https://facebook.com/...&#10;https://twitter.com/...">%s</textarea>', esc_textarea($value));
1582 1719 echo '<p class="description">Enter one social profile URL per line.</p>';
1583 1720 },
1584 1721 $schema_page,
1585 1722 'metasync_schema_org'
@@ -1599,9 +1736,9 @@
1599 1736 'WebSite Name',
1600 1737 function() use ($option_key) {
1601 1738 $schema = Metasync::get_option('schema') ?? [];
1602 1739 $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));
1740 + printf('<input type="text" name="' . esc_attr($option_key) . '[schema][website_name]" value="%s" size="40" placeholder="Your website name" />', esc_attr($value));
1604 1741 },
1605 1742 $schema_page,
1606 1743 'metasync_schema_website'
1607 1744 );
@@ -1611,9 +1748,9 @@
1611 1748 'WebSite URL',
1612 1749 function() use ($option_key) {
1613 1750 $schema = Metasync::get_option('schema') ?? [];
1614 1751 $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));
1752 + printf('<input type="url" name="' . esc_attr($option_key) . '[schema][website_url]" value="%s" size="40" placeholder="https://example.com" />', esc_attr($value));
1616 1753 },
1617 1754 $schema_page,
1618 1755 'metasync_schema_website'
1619 1756 );
@@ -1624,9 +1761,9 @@
1624 1761 function() use ($option_key) {
1625 1762 $schema = Metasync::get_option('schema') ?? [];
1626 1763 $value = $schema['website_searchbox'] ?? false;
1627 1764 printf(
1628 - '<input type="checkbox" name="' . $option_key . '[schema][website_searchbox]" value="1" %s />',
1765 + '<input type="checkbox" name="' . esc_attr($option_key) . '[schema][website_searchbox]" value="1" %s />',
1629 1766 checked($value, true, false)
1630 1767 );
1631 1768 echo '<span class="description">Adds SearchAction to WebSite schema for Google Sitelinks Searchbox.</span>';
1632 1769 },
@@ -1649,9 +1786,9 @@
1649 1786 function() use ($option_key) {
1650 1787 $schema = Metasync::get_option('schema') ?? [];
1651 1788 $value = $schema['override_woocommerce_schema'] ?? false;
1652 1789 printf(
1653 - '<input type="checkbox" name="' . $option_key . '[schema][override_woocommerce_schema]" value="1" %s />',
1790 + '<input type="checkbox" name="' . esc_attr($option_key) . '[schema][override_woocommerce_schema]" value="1" %s />',
1654 1791 checked($value, true, false)
1655 1792 );
1656 1793 echo '<span class="description">When unchecked (default), MetaSync suppresses its Product schema when WooCommerce is active to avoid duplication. Check to override WooCommerce\'s native Product schema.</span>';
1657 1794 },
@@ -1705,9 +1842,9 @@
1705 1842 'baidu_site_verification',
1706 1843 'Baidu' . Metasync::get_tooltip_icon_html('baidu_site_verification_tooltip', 'Paste the verification code Baidu Webmaster gave you. This lets Baidu confirm you own this site — it does not change anything visitors see.'),
1707 1844 function() use ($option_key) {
1708 1845 $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 ) );
1846 + printf( '<input type="text" id="baidu_site_verification" name="' . esc_attr($option_key) . '[searchengines][baidu_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1710 1847 },
1711 1848 $page_slug . '_searchengines-verification',
1712 1849 $SECTION_SEARCHENGINE
1713 1850 );
@@ -1716,9 +1853,9 @@
1716 1853 'alexa_site_verification',
1717 1854 'Alexa' . Metasync::get_tooltip_icon_html('alexa_site_verification_tooltip', 'Paste the verification code your Alexa/site directory gave you. This confirms you own this site — it does not change anything visitors see.'),
1718 1855 function() use ($option_key) {
1719 1856 $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 ) );
1857 + printf( '<input type="text" id="alexa_site_verification" name="' . esc_attr($option_key) . '[searchengines][alexa_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1721 1858 },
1722 1859 $page_slug . '_searchengines-verification',
1723 1860 $SECTION_SEARCHENGINE
1724 1861 );
@@ -1727,9 +1864,9 @@
1727 1864 'norton_save_site_verification',
1728 1865 'Norton Safe Web' . Metasync::get_tooltip_icon_html('norton_site_verification_tooltip', 'Paste the verification code Norton Safe Web gave you. This confirms you own this site — it does not change anything visitors see.'),
1729 1866 function() use ($option_key) {
1730 1867 $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 ) );
1868 + printf( '<input type="text" id="norton_save_site_verification" name="' . esc_attr($option_key) . '[searchengines][norton_save_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1732 1869 },
1733 1870 $page_slug . '_searchengines-verification',
1734 1871 $SECTION_SEARCHENGINE
1735 1872 );
@@ -1788,121 +1925,14 @@
1788 1925 add_settings_field( 'local_seo_map_key', 'Google Maps Key' . Metasync::get_tooltip_icon_html('local_seo_map_key_tooltip', 'A Google Maps API key from your Google Cloud account. Only needed if you want an embedded map; it lets Google bill and authorize the map requests.'), array( $fields_instance, 'local_seo_map_key_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1789 1926 add_settings_field( 'local_seo_geo_coordinates', 'Geo Coordinates' . Metasync::get_tooltip_icon_html('local_seo_geo_coordinates_tooltip', 'The exact latitude and longitude of your business. This helps search engines and maps pin your location precisely, especially when your street address is ambiguous.'), array( $fields_instance, 'local_seo_geo_coordinates_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1790 1927
1791 1928 // ----------------------------------------------------------------
1792 - // Breadcrumbs page
1929 + // Breadcrumbs page — intentionally registers no Settings API fields.
1930 + // The dedicated Breadcrumbs admin page renders the richer controls
1931 + // directly via Metasync_Settings_Fields::render_breadcrumbs_section()
1932 + // and writes the same metasync_options[breadcrumbs] keys.
1793 1933 // ----------------------------------------------------------------
1794 - add_settings_section(
1795 - $SECTION_BREADCRUMBS,
1796 - '',
1797 - function() {},
1798 - $page_slug . '_breadcrumbs'
1799 - );
1800 1934
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 1935 # Open Graph / Article meta toggles — surface each new tag family as its
1906 1936 # own opt-out checkbox on the main Settings page (Open Graph accordion).
1907 1937 add_settings_field(
1908 1938 'og_image_dimensions',
@@ -1954,8 +1984,97 @@
1954 1984
1955 1985 }
1956 1986
1957 1987 /**
1988 + * Coerce a loosely-typed option value into an array.
1989 + *
1990 + * The value arrives untyped (a crafted or partial submission may send a
1991 + * scalar for an option stored as an array), so the check lives behind an
1992 + * untyped parameter instead of an inline is_array() guard on a value the
1993 + * docblock already narrows.
1994 + *
1995 + * @param mixed $value Raw option value.
1996 + * @return array The value as an array, or an empty array.
1997 + */
1998 + private static function coerce_to_array($value)
1999 + {
2000 + return is_array($value) ? $value : array();
2001 + }
2002 +
2003 + /**
2004 + * Recursively merge sanitized settings over raw and stored layers.
2005 + *
2006 + * Later layers win per-key; associative arrays merge key-by-key so
2007 + * submitted values only replace their own field, while sequential
2008 + * lists (rows of opening hours, phone numbers) replace wholesale
2009 + * so deleted rows stay deleted.
2010 + *
2011 + * @param array $base Stored option layer (lowest precedence).
2012 + * @param array ...$layers Layers to fold in, lowest precedence first
2013 + * (raw input, then sanitized values).
2014 + * @return array The merged option array.
2015 + */
2016 + private static function merge_option_layers(array $base, array ...$layers)
2017 + {
2018 + foreach ($layers as $layer) {
2019 + foreach ($layer as $key => $value) {
2020 + if (is_array($value)
2021 + && isset($base[$key])
2022 + && is_array($base[$key])
2023 + && !self::is_sequential_list($value)
2024 + && !self::clears_a_stored_list($value, $base[$key])
2025 + ) {
2026 + $base[$key] = self::merge_option_layers($base[$key], $value);
2027 + } else {
2028 + $base[$key] = $value;
2029 + }
2030 + }
2031 + }
2032 + return $base;
2033 + }
2034 +
2035 + /**
2036 + * Whether the array is a numerically-indexed list (keys 0..n-1).
2037 + *
2038 + * Answers false for the empty array — range(0, -1) counts downwards and
2039 + * yields [0, -1], not []. That is load-bearing rather than incidental: an
2040 + * empty array is ambiguous, and clears_a_stored_list() is where the
2041 + * ambiguity is resolved.
2042 + *
2043 + * @param array $value Array to inspect.
2044 + * @return bool True for lists, false for associative maps.
2045 + */
2046 + private static function is_sequential_list(array $value)
2047 + {
2048 + return array_keys($value) === range(0, count($value) - 1);
2049 + }
2050 +
2051 + /**
2052 + * Whether an empty submitted value is a stored list being cleared.
2053 + *
2054 + * An empty array reaches the merge meaning one of two opposite things, and
2055 + * only the stored value tells them apart.
2056 + *
2057 + * When what is stored is a list — the roles of a multi-select, the rows of
2058 + * a repeater — an empty submission means the user removed every entry, and
2059 + * it has to replace the stored value or the clear is silently ignored.
2060 + *
2061 + * When what is stored is an associative map, an empty submission means the
2062 + * form carried nothing for that section: a tab whose every checkbox is
2063 + * unchecked posts no fields at all. Replacing there would wipe the section,
2064 + * API key included, which is the whole failure this merge exists to
2065 + * prevent. So that case still merges, leaving the stored keys standing.
2066 + *
2067 + * @param array $value The submitted value.
2068 + * @param array $stored The value already in the option.
2069 + * @return bool True when the submission is clearing a list.
2070 + */
2071 + private static function clears_a_stored_list(array $value, array $stored)
2072 + {
2073 + return $value === array() && self::is_sequential_list($stored);
2074 + }
2075 +
2076 + /**
1958 2077 * Sanitize each setting field as needed.
1959 2078 *
1960 2079 * Formerly Metasync_Admin::sanitize().
1961 2080 *
@@ -1963,21 +2082,62 @@
1963 2082 * @return array Sanitized settings
1964 2083 */
1965 2084 public function sanitize($input)
1966 2085 {
1967 - $new_input = Metasync::get_option();
2086 + $submitted_generation = isset($_POST['metasync_pw_reset_generation']) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2087 + ? absint($_POST['metasync_pw_reset_generation']) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2088 + : null;
2089 + // Legacy/stale forms that include a password but no reset generation
2090 + // must never be allowed to overwrite a newer recovery password.
2091 + if (!self::$allow_recovery_password_write
2092 + && $submitted_generation === null
2093 + && isset($input['whitelabel']['settings_password'])) {
2094 + unset($input['whitelabel']['settings_password']);
2095 + add_settings_error(
2096 + 'metasync_messages',
2097 + 'metasync_password_generation_missing',
2098 + __('This settings form is stale. Reload the page before changing the password.', 'metasync'),
2099 + 'error'
2100 + );
2101 + }
2102 + // Every settings save rewrites the aggregate option, including its
2103 + // current password value. Serialize the full save with recovery even
2104 + // when the submitted tab does not expose the password field.
2105 + if (!$this->acquire_settings_recovery_lock()) {
2106 + add_settings_error(
2107 + 'metasync_messages',
2108 + 'metasync_password_update_busy',
2109 + __('Password recovery is being processed. No settings were saved; please try again.', 'metasync'),
2110 + 'error'
2111 + );
2112 + } else {
2113 + self::$options_generation_at_sanitize = $submitted_generation !== null
2114 + ? $submitted_generation
2115 + : (int) get_option('metasync_pw_reset_generation', 0);
2116 + }
2117 + $input = self::coerce_to_array($input);
2118 +
2119 + // The saved option is assembled from three layers (see the merge at
2120 + // the end of this method): the stored option, the raw submission,
2121 + // and the sanitized values collected in $new_input. Sanitization
2122 + // therefore wins over raw input, a submitted field without a
2123 + // sanitizer still beats its stored value, and stored keys the
2124 + // submission omits survive instead of being dropped.
2125 + $stored_option = self::coerce_to_array(Metasync::get_option());
2126 + $new_input = array();
1968 2127 $whitelabel_validation_failed = false;
2128 + $whitelabel_reset = false;
1969 2129
1970 2130 # Determine which tab is being submitted (same logic as AJAX handler)
1971 2131 # Check POST first (from AJAX), then GET (from form action URL)
1972 2132 $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'])) {
2133 + if (isset($_POST['active_tab'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2134 + $active_tab = sanitize_text_field(wp_unslash($_POST['active_tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2135 + } elseif (isset($_GET['tab'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- sanitize callback, the caller already verified the nonce
2136 + $active_tab = sanitize_text_field(wp_unslash($_GET['tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- sanitize callback, the caller already verified the nonce
2137 + } elseif (isset($_POST['_wp_http_referer'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
1978 2138 # Parse referer URL to get tab parameter
1979 - $referer = wp_parse_url($_POST['_wp_http_referer']);
2139 + $referer = wp_parse_url(esc_url_raw(wp_unslash($_POST['_wp_http_referer']))); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
1980 2140 if (isset($referer['query'])) {
1981 2141 parse_str($referer['query'], $referer_params);
1982 2142 if (isset($referer_params['tab'])) {
1983 2143 $active_tab = sanitize_text_field($referer_params['tab']);
@@ -1999,10 +2159,15 @@
1999 2159 'disable_redirection_metabox',
2000 2160 'disable_canonical_metabox',
2001 2161 'disable_social_opengraph_metabox',
2002 2162 'disable_schema_markup_metabox',
2163 + 'disable_language_alternates_metabox',
2003 2164 'disable_seo_metabox',
2004 - 'open_external_links'
2165 + 'open_external_links',
2166 + // Consent switch for writing into other SEO plugins. Absent from
2167 + // the POST means unchecked, which must resolve to off — a
2168 + // permanent write into another plugin's data is never inferred.
2169 + Metasync_Seo_Backup::CONSENT_OPTION_KEY
2005 2170 ];
2006 2171
2007 2172 foreach ($checkbox_fields as $field) {
2008 2173 if (isset($input['general'][$field])) {
@@ -2007,15 +2172,27 @@
2007 2172 foreach ($checkbox_fields as $field) {
2008 2173 if (isset($input['general'][$field])) {
2009 2174 $new_input['general'][$field] = filter_var($input['general'][$field], FILTER_VALIDATE_BOOLEAN);
2010 2175 } else {
2176 + // Unchecked HTML checkboxes are absent from the submission;
2177 + // persist an explicit false so the stored value cannot leak
2178 + // back through the layered merge.
2011 2179 $new_input['general'][$field] = false;
2012 2180 }
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 2181 }
2182 +
2183 + // SEO priority is a checkbox whose stored value is a string, so it
2184 + // cannot ride the boolean loop above. Only the exact opt-in value
2185 + // is accepted; unchecked (absent) or anything else resolves to
2186 + // custom-first. Writing an explicit value rather than leaving the
2187 + // key absent matters for the same reason the loop above writes
2188 + // false: the layered merge would otherwise let the stored 'otto'
2189 + // leak back and the box could never be unticked.
2190 + $new_input['general']['seo_priority'] =
2191 + (isset($input['general']['seo_priority'])
2192 + && $input['general']['seo_priority'] === Metasync_Seo_Precedence::PRIORITY_OTTO)
2193 + ? Metasync_Seo_Precedence::PRIORITY_OTTO
2194 + : 'custom';
2018 2195 }
2019 2196
2020 2197 // Site Verification Settings
2021 2198 if (isset($input['searchengines']['bing_site_verification'])) {
@@ -2117,13 +2294,28 @@
2117 2294 }
2118 2295
2119 2296 // Breadcrumbs Settings
2120 2297 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']);
2298 + // Checkbox fields are only written when the submitting form
2299 + // actually rendered them. render_breadcrumbs_section() pairs every
2300 + // checkbox with a hidden companion input, so the key is always
2301 + // present there and unchecking works. A missing key means the form
2302 + // does not manage that field, so the stored value is preserved
2303 + // instead of being silently reset to false.
2304 + foreach (array('enabled', 'show_current_page', 'disable_schema') as $breadcrumb_flag) {
2305 + if (isset($input['breadcrumbs'][$breadcrumb_flag])) {
2306 + $new_input['breadcrumbs'][$breadcrumb_flag] = !empty($input['breadcrumbs'][$breadcrumb_flag]);
2307 + }
2308 + }
2309 + // The Custom radio always renders and can submit an empty value
2310 + // when selected without typing. An empty separator would render
2311 + // a blank gap between breadcrumb links, so it is dropped and the
2312 + // stored value survives, exactly like an absent key.
2124 2313 if (isset($input['breadcrumbs']['separator'])) {
2125 - $new_input['breadcrumbs']['separator'] = sanitize_text_field($input['breadcrumbs']['separator']);
2314 + $breadcrumb_separator = sanitize_text_field($input['breadcrumbs']['separator']);
2315 + if ('' !== $breadcrumb_separator) {
2316 + $new_input['breadcrumbs']['separator'] = $breadcrumb_separator;
2317 + }
2126 2318 }
2127 2319 if (isset($input['breadcrumbs']['home_label'])) {
2128 2320 $new_input['breadcrumbs']['home_label'] = sanitize_text_field($input['breadcrumbs']['home_label']);
2129 2321 }
@@ -2135,19 +2327,25 @@
2135 2327 }
2136 2328 if (isset($input['breadcrumbs']['archive_label_format'])) {
2137 2329 $new_input['breadcrumbs']['archive_label_format'] = sanitize_text_field($input['breadcrumbs']['archive_label_format']);
2138 2330 }
2139 - // Remove raw breadcrumbs so sanitized values (especially checkbox
2140 - // booleans) survive the shallow array_merge($new_input, $input).
2331 + // Drop the raw section: the sanitized copies above (including the
2332 + // explicit checkbox booleans) are authoritative for the merge.
2141 2333 unset($input['breadcrumbs']);
2142 2334 }
2143 2335
2144 - // Code Snippets Settings
2336 + // Code Snippets Settings. These fields intentionally hold raw markup
2337 + // (analytics scripts, verification tags, inline styles) that is echoed
2338 + // verbatim on the front end, so only the request slashes are stripped —
2339 + // tag-stripping sanitizers would destroy the snippet. The form is
2340 + // restricted to users with plugin settings access.
2145 2341 if (isset($input['codesnippets']['header_snippet'])) {
2146 - $new_input['codesnippets']['header_snippet'] = sanitize_text_field($input['codesnippets']['header_snippet']);
2342 + $snippet = wp_unslash($input['codesnippets']['header_snippet']);
2343 + $new_input['codesnippets']['header_snippet'] = is_string($snippet) ? trim($snippet) : '';
2147 2344 }
2148 2345 if (isset($input['codesnippets']['footer_snippet'])) {
2149 - $new_input['codesnippets']['footer_snippet'] = sanitize_text_field($input['codesnippets']['footer_snippet']);
2346 + $snippet = wp_unslash($input['codesnippets']['footer_snippet']);
2347 + $new_input['codesnippets']['footer_snippet'] = is_string($snippet) ? trim($snippet) : '';
2150 2348 }
2151 2349
2152 2350 // Common Setting - Global Settings
2153 2351 $common_robots_field = isset($input['common_robots_meta']) ? 'common_robots_meta' : 'common_robots_mata';
@@ -2224,10 +2422,10 @@
2224 2422 ];
2225 2423 foreach ($og_toggle_fields as $field) {
2226 2424 $new_input['common_meta_settings'][$field] = (isset($input['common_meta_settings'][$field]) && $input['common_meta_settings'][$field] === 'true') ? 'true' : 'false';
2227 2425 }
2228 - // Remove raw common_meta_settings so sanitized toggle values
2229 - // survive the shallow array_merge($new_input, $input).
2426 + // Drop the raw section so only the sanitized toggle values reach
2427 + // the stored option.
2230 2428 unset($input['common_meta_settings']);
2231 2429 }
2232 2430
2233 2431 # Handle whitelabel URL fields with improved empty value handling
@@ -2307,10 +2505,31 @@
2307 2505 }
2308 2506 }
2309 2507
2310 2508 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));
2509 + // WordPress unslashes registered settings before invoking the
2510 + // sanitize callback. Preserve the resulting password exactly.
2511 + $password_value = (string) $input['whitelabel']['settings_password'];
2512 + $encrypted_password = Metasync::encrypt_secret($password_value);
2513 + if ($password_value === '') {
2514 + $new_input['whitelabel']['settings_password'] = '';
2515 + } elseif (Metasync::is_encrypted_secret($password_value)) {
2516 + // update_option() may invoke this sanitizer after the
2517 + // AJAX handler has already encrypted the value. Treat
2518 + // valid ciphertext as an idempotent input, not a failed
2519 + // encryption attempt.
2520 + $new_input['whitelabel']['settings_password'] = $password_value;
2521 + } elseif (Metasync::is_encrypted_secret($encrypted_password) && $encrypted_password !== $password_value) {
2522 + $new_input['whitelabel']['settings_password'] = $encrypted_password;
2523 + } else {
2524 + $new_input['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'] ?? '';
2525 + add_settings_error(
2526 + 'metasync_messages',
2527 + 'metasync_password_encryption_failed',
2528 + __('Unable to securely save the password. Please choose a different password.', 'metasync'),
2529 + 'error'
2530 + );
2531 + }
2313 2532 } else {
2314 2533 if (isset($existing_whitelabel['settings_password'])) {
2315 2534 $new_input['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'];
2316 2535 }
@@ -2415,12 +2634,15 @@
2415 2634 $existing_whitelabel = Metasync::get_option()['whitelabel'] ?? [];
2416 2635 $has_existing_whitelabel = !empty($existing_whitelabel['domain']) || !empty($existing_whitelabel['logo']);
2417 2636
2418 2637 if ($has_existing_whitelabel) {
2638 + // A reset replaces the whole section (password ciphertext,
2639 + // palette, quick links and all) rather than merging leaves.
2640 + $whitelabel_reset = true;
2419 2641 $new_input['whitelabel'] = [
2420 2642 'is_whitelabel' => false,
2421 2643 'domain' => '',
2422 - 'logo' => '',
2644 + 'logo' => '',
2423 2645 'company_name' => '',
2424 2646 'updated_at' => time()
2425 2647 ];
2426 2648
@@ -2455,10 +2677,42 @@
2455 2677 do_action('metasync_trigger_immediate_heartbeat', 'Whitelabel domain change - ' . $context);
2456 2678 });
2457 2679 }
2458 2680
2459 - $result = array_merge($new_input, $input);
2681 + // Headless settings are never written through this form. They are owned by
2682 + // Metasync_Headless_Config and saved by their own handler, so anything
2683 + // arriving here under that key is a stale form or a hand-built request.
2684 + // The merge below would let it write over the stored block key by key —
2685 + // switching the mode off that way removes the GraphQL field and breaks
2686 + // every persisted query a headless frontend holds, so the key is dropped
2687 + // rather than merged.
2688 + //
2689 + // Except when the owner is the one writing. register_setting() makes this
2690 + // method a `sanitize_option_metasync_options` filter, and WordPress runs
2691 + // it on every update_option() call for the option, not only on Settings
2692 + // API form posts — so dropping the key unconditionally also dropped
2693 + // Metasync_Headless_Config's own writes, and the Headless Mode block
2694 + // could never save anything on a real request.
2695 + if (!Metasync_Headless_Config::is_writing()) {
2696 + unset($input[Metasync_Headless_Config::SETTINGS_KEY]);
2697 + }
2460 2698
2699 + // Keep the fully sanitized/encrypted white-label values. The layered
2700 + // merge below already lets them win; mirroring them into the raw
2701 + // layer also excludes stray raw whitelabel keys from the saved option.
2702 + if (isset($input['whitelabel'], $new_input['whitelabel'])) {
2703 + $input['whitelabel'] = $new_input['whitelabel'];
2704 + }
2705 +
2706 + // Layered merge: stored option, then the raw submission, then the
2707 + // sanitized values. Replaces the former shallow array_merge(), which
2708 + // let the raw submission overwrite every sanitized section wholesale.
2709 + $result = self::merge_option_layers($stored_option, $input, $new_input);
2710 +
2711 + if ($whitelabel_reset) {
2712 + $result['whitelabel'] = $new_input['whitelabel'];
2713 + }
2714 +
2461 2715 if ($whitelabel_validation_failed) {
2462 2716 $result['whitelabel'] = Metasync::get_option()['whitelabel'] ?? [];
2463 2717 }
2464 2718
@@ -2473,9 +2727,10 @@
2473 2727 * Moved from Metasync_Admin – the admin class now delegates here.
2474 2728 */
2475 2729 public function meta_sync_save_settings()
2476 2730 {
2477 - if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce($_POST['meta_sync_nonce'], 'meta_sync_general_setting_nonce')) {
2731 + $meta_sync_nonce = isset($_POST['meta_sync_nonce']) ? sanitize_text_field(wp_unslash($_POST['meta_sync_nonce'])) : '';
2732 + if (!wp_verify_nonce($meta_sync_nonce, 'meta_sync_general_setting_nonce')) {
2478 2733 wp_send_json_error(array('message' => 'Invalid nonce'));
2479 2734 return;
2480 2735 }
2481 2736
@@ -2483,8 +2738,31 @@
2483 2738 wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
2484 2739 return;
2485 2740 }
2486 2741
2742 + self::$defer_settings_recovery_lock_release = true;
2743 + if (!$this->acquire_settings_recovery_lock()) {
2744 + self::$defer_settings_recovery_lock_release = false;
2745 + self::$abort_locked_settings_update = false;
2746 + return wp_send_json_error(array('message' => 'Password recovery is being processed. No settings were saved; please try again.'), 409);
2747 + }
2748 +
2749 + $current_generation = (int) get_option('metasync_pw_reset_generation', 0);
2750 + $password_submitted = isset($_POST['metasync_options']['whitelabel']['settings_password']);
2751 + if ($password_submitted && !isset($_POST['metasync_pw_reset_generation'])) {
2752 + $this->force_release_settings_recovery_lock();
2753 + return wp_send_json_error(array('message' => 'A settings password update requires a current page generation. Reload the page and try again.'), 409);
2754 + }
2755 + if (isset($_POST['metasync_pw_reset_generation'])) {
2756 + self::$options_generation_at_sanitize = absint($_POST['metasync_pw_reset_generation']);
2757 + if ((int) self::$options_generation_at_sanitize !== $current_generation) {
2758 + $this->force_release_settings_recovery_lock();
2759 + return wp_send_json_error(array('message' => 'The settings password changed while this page was open. Reload the page and try again.'), 409);
2760 + }
2761 + } else {
2762 + self::$options_generation_at_sanitize = $current_generation;
2763 + }
2764 +
2487 2765 $text_fields = [
2488 2766 'searchatlas_api_key', 'apikey',
2489 2767 'white_label_plugin_name', 'white_label_plugin_description',
2490 2768 'white_label_plugin_author', 'white_label_plugin_menu_slug',
@@ -2499,9 +2777,9 @@
2499 2777 $url_fields = [
2500 2778 'white_label_plugin_author_uri', 'white_label_plugin_uri'
2501 2779 ];
2502 2780
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'];
2781 + $bool_fields = ['otto_disable_on_loggedin', 'otto_disable_preview_button', 'otto_disable_for_bots' , 'hide_dashboard_framework', 'show_admin_bar_status','disable_common_robots_metabox', 'disable_advance_robots_metabox', 'disable_redirection_metabox', 'disable_canonical_metabox', 'disable_social_opengraph_metabox', 'disable_schema_markup_metabox', 'disable_language_alternates_metabox', 'disable_seo_metabox', 'open_external_links', Metasync_Seo_Backup::CONSENT_OPTION_KEY];
2504 2782
2505 2783 $url_fields = ['white_label_plugin_author_uri', 'white_label_plugin_uri'];
2506 2784
2507 2785 $metasync_options = Metasync::get_option();
@@ -2513,9 +2791,9 @@
2513 2791 }
2514 2792
2515 2793 $validation_errors = [];
2516 2794
2517 - $active_tab = isset($_POST['active_tab']) ? sanitize_text_field($_POST['active_tab']) : 'general';
2795 + $active_tab = isset($_POST['active_tab']) ? sanitize_text_field(wp_unslash($_POST['active_tab'])) : 'general';
2518 2796 $general_tab_submitted = ($active_tab === 'general');
2519 2797 $whitelabel_tab_submitted = ($active_tab === 'whitelabel');
2520 2798
2521 2799 // Whitelabel field validation issues are collected per-field and reported back
@@ -2525,9 +2803,10 @@
2525 2803 foreach ($text_fields as $field) {
2526 2804
2527 2805 if (isset($_POST['metasync_options']['general'][$field])) {
2528 2806
2529 - $value = trim($_POST['metasync_options']['general'][$field]);
2807 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every storing branch below sanitises via sanitize_text_field/sanitize_title/esc_url_raw before the value reaches the option.
2808 + $value = trim(wp_unslash($_POST['metasync_options']['general'][$field]));
2530 2809
2531 2810 // searchatlas_api_key must be clearable to support key removal
2532 2811 $clearable_fields = [
2533 2812 'searchatlas_api_key',
@@ -2564,9 +2843,9 @@
2564 2843 } elseif (filter_var($value, FILTER_VALIDATE_URL)) {
2565 2844
2566 2845 $image_extensions = ['png', 'svg'];
2567 2846
2568 - $path = parse_url($value, PHP_URL_PATH);
2847 + $path = wp_parse_url($value, PHP_URL_PATH);
2569 2848 $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
2570 2849
2571 2850 if (in_array($extension, $image_extensions, true)) {
2572 2851 $metasync_options['general'][$field] = esc_url_raw($value);
@@ -2580,9 +2859,9 @@
2580 2859 // Self-correcting slug: a URL-shaped value is reduced to a valid WP
2581 2860 // menu slug so it can never break sidebar link generation.
2582 2861 $metasync_options['general'][$field] = sanitize_title($value);
2583 2862 } else {
2584 - $metasync_options['general'][$field] = sanitize_text_field($_POST['metasync_options']['general'][$field]);
2863 + $metasync_options['general'][$field] = sanitize_text_field(wp_unslash($_POST['metasync_options']['general'][$field]));
2585 2864 }
2586 2865 }
2587 2866 }
2588 2867
@@ -2589,9 +2868,9 @@
2589 2868 $textarea_fields = ['otto_bot_whitelist', 'otto_bot_blacklist'];
2590 2869 if ($general_tab_submitted) {
2591 2870 foreach ($textarea_fields as $field) {
2592 2871 if (isset($_POST['metasync_options']['general'][$field])) {
2593 - $metasync_options['general'][$field] = sanitize_textarea_field($_POST['metasync_options']['general'][$field]);
2872 + $metasync_options['general'][$field] = sanitize_textarea_field(wp_unslash($_POST['metasync_options']['general'][$field]));
2594 2873 } else {
2595 2874 $metasync_options['general'][$field] = '';
2596 2875 }
2597 2876 }
@@ -2599,13 +2878,23 @@
2599 2878
2600 2879 if ($general_tab_submitted) {
2601 2880 foreach ($bool_fields as $field) {
2602 2881 if (isset($_POST['metasync_options']['general'][$field])) {
2603 - $metasync_options['general'][$field] = filter_var($_POST['metasync_options']['general'][$field], FILTER_VALIDATE_BOOLEAN);
2882 + $metasync_options['general'][$field] = filter_var(wp_unslash($_POST['metasync_options']['general'][$field]), FILTER_VALIDATE_BOOLEAN);
2604 2883 }else {
2605 2884 $metasync_options['general'][$field] = false;
2606 2885 }
2607 2886 }
2887 +
2888 + // SEO priority is a checkbox that stores a string rather than a
2889 + // bool, so it cannot ride the loop above. Only the exact opt-in
2890 + // value is stored; an unchecked box (or any tampered payload)
2891 + // falls back to custom-first, which is the pre-existing behaviour.
2892 + $metasync_options['general']['seo_priority'] =
2893 + (isset($_POST['metasync_options']['general']['seo_priority'])
2894 + && $_POST['metasync_options']['general']['seo_priority'] === Metasync_Seo_Precedence::PRIORITY_OTTO)
2895 + ? Metasync_Seo_Precedence::PRIORITY_OTTO
2896 + : 'custom';
2608 2897 }
2609 2898
2610 2899 if ($general_tab_submitted || $whitelabel_tab_submitted) {
2611 2900 foreach ($url_fields as $field) {
@@ -2610,9 +2899,10 @@
2610 2899 if ($general_tab_submitted || $whitelabel_tab_submitted) {
2611 2900 foreach ($url_fields as $field) {
2612 2901 if (isset($_POST['metasync_options']['general'][$field])) {
2613 2902
2614 - $value = trim($_POST['metasync_options']['general'][$field]);
2903 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every storing branch below sanitises via sanitize_text_field/sanitize_title/esc_url_raw before the value reaches the option.
2904 + $value = trim(wp_unslash($_POST['metasync_options']['general'][$field]));
2615 2905
2616 2906 $whitelabel_clearable_url_fields = [
2617 2907 'white_label_plugin_author_uri',
2618 2908 'white_label_plugin_uri'
@@ -2628,9 +2918,9 @@
2628 2918 }
2629 2919
2630 2920 if (filter_var($value, FILTER_VALIDATE_URL)) {
2631 2921
2632 - $parsed_url = parse_url($value);
2922 + $parsed_url = wp_parse_url($value);
2633 2923 $host = isset($parsed_url['host']) ? $parsed_url['host'] : '';
2634 2924
2635 2925 if (strpos($host, '.') !== false || filter_var($host, FILTER_VALIDATE_IP)) {
2636 2926 $metasync_options['general'][$field] = esc_url_raw($value);
@@ -2647,31 +2937,54 @@
2647 2937 }
2648 2938
2649 2939 if ($general_tab_submitted) {
2650 2940 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']);
2941 + $metasync_options['general']['content_genius_sync_roles'] = array_map('sanitize_text_field', wp_unslash($_POST['metasync_options']['general']['content_genius_sync_roles']));
2652 2942 } else {
2653 2943 $metasync_options['general']['content_genius_sync_roles'] = array();
2654 2944 }
2655 2945 }
2656 2946
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();
2947 + // Breadcrumb settings live on their own admin page, which saves through
2948 + // options.php -> sanitize() rather than this AJAX handler. Only touch
2949 + // them here if the submitted form actually carried breadcrumb fields,
2950 + // otherwise a General-tab save would reset every breadcrumb option.
2951 + if ($general_tab_submitted
2952 + && isset($_POST['metasync_options']['breadcrumbs'])
2953 + && is_array($_POST['metasync_options']['breadcrumbs'])
2954 + ) {
2955 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each key is sanitised below (bool cast for flags, sanitize_text_field for the separator).
2956 + $breadcrumbs_input = wp_unslash($_POST['metasync_options']['breadcrumbs']);
2661 2957
2662 2958 if (!isset($metasync_options['breadcrumbs']) || !is_array($metasync_options['breadcrumbs'])) {
2663 2959 $metasync_options['breadcrumbs'] = array();
2664 2960 }
2665 2961
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}';
2962 + foreach (array('enabled', 'disable_schema', 'show_current_page') as $breadcrumb_flag) {
2963 + if (isset($breadcrumbs_input[$breadcrumb_flag])) {
2964 + $metasync_options['breadcrumbs'][$breadcrumb_flag] = !empty($breadcrumbs_input[$breadcrumb_flag]);
2965 + }
2966 + }
2967 + // An empty separator (Custom radio selected blank) keeps the
2968 + // stored value instead of rendering a blank gap between links.
2969 + if (isset($breadcrumbs_input['separator'])) {
2970 + $breadcrumb_separator = sanitize_text_field($breadcrumbs_input['separator']);
2971 + if ('' !== $breadcrumb_separator) {
2972 + $metasync_options['breadcrumbs']['separator'] = $breadcrumb_separator;
2973 + }
2974 + }
2975 + if (isset($breadcrumbs_input['home_label'])) {
2976 + $metasync_options['breadcrumbs']['home_label'] = sanitize_text_field($breadcrumbs_input['home_label']);
2977 + }
2978 + if (isset($breadcrumbs_input['home_url'])) {
2979 + $metasync_options['breadcrumbs']['home_url'] = esc_url_raw($breadcrumbs_input['home_url']);
2980 + }
2981 + if (isset($breadcrumbs_input['prefix_text'])) {
2982 + $metasync_options['breadcrumbs']['prefix_text'] = sanitize_text_field($breadcrumbs_input['prefix_text']);
2983 + }
2984 + if (isset($breadcrumbs_input['archive_label_format'])) {
2985 + $metasync_options['breadcrumbs']['archive_label_format'] = sanitize_text_field($breadcrumbs_input['archive_label_format']);
2986 + }
2674 2987 }
2675 2988
2676 2989 // Open Graph / article toggle checkboxes live under common_meta_settings.
2677 2990 // Unchecked HTML checkboxes are absent from $_POST, so we explicitly
@@ -2692,9 +3005,9 @@
2692 3005 $metasync_options['common_meta_settings'] = [];
2693 3006 }
2694 3007 foreach ($og_toggle_fields as $field) {
2695 3008 $posted = isset($_POST['metasync_options']['common_meta_settings'][$field])
2696 - ? sanitize_text_field($_POST['metasync_options']['common_meta_settings'][$field])
3009 + ? sanitize_text_field(wp_unslash($_POST['metasync_options']['common_meta_settings'][$field]))
2697 3010 : '';
2698 3011 $metasync_options['common_meta_settings'][$field] = ($posted === 'true') ? 'true' : 'false';
2699 3012 }
2700 3013 }
@@ -2701,14 +3014,15 @@
2701 3014
2702 3015 if ($general_tab_submitted && isset($_POST['metasync_options']['general']['default_page_builder'])) {
2703 3016 require_once plugin_dir_path(dirname(__FILE__)) . 'custom-pages/class-metasync-html-to-builder-converter.php';
2704 3017 $available_builders = Metasync_HTML_To_Builder_Converter::get_available_builders();
2705 - $builder_value = sanitize_text_field($_POST['metasync_options']['general']['default_page_builder']);
3018 + $builder_value = sanitize_text_field(wp_unslash($_POST['metasync_options']['general']['default_page_builder']));
2706 3019 if (isset($available_builders[$builder_value]) && $available_builders[$builder_value]['detected']) {
2707 3020 $metasync_options['general']['default_page_builder'] = $builder_value;
2708 3021 } else {
2709 3022 $metasync_options['general']['default_page_builder'] = 'gutenberg';
2710 3023 if (isset($available_builders[$builder_value]) && !$available_builders[$builder_value]['detected']) {
3024 + /* translators: %s: page builder name. */
2711 3025 $validation_errors[] = sprintf(__('Builder "%s" is not detected on this site. Reverted to Gutenberg.', 'metasync'), $available_builders[$builder_value]['label']);
2712 3026 }
2713 3027 }
2714 3028 }
@@ -2713,10 +3027,13 @@
2713 3027 }
2714 3028 }
2715 3029
2716 3030 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'] : [];
3031 + // Values are only previewed from the just-sanitised $metasync_options below; nothing here is stored.
3032 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- preview-only read
3033 + $gp = isset($_POST['metasync_options']['general']) && is_array($_POST['metasync_options']['general']) ? wp_unslash($_POST['metasync_options']['general']) : [];
3034 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- preview-only read
3035 + $wp = isset($_POST['metasync_options']['whitelabel']) && is_array($_POST['metasync_options']['whitelabel']) ? wp_unslash($_POST['metasync_options']['whitelabel']) : [];
2719 3036 $plugin_name = isset($gp['white_label_plugin_name']) ? trim((string) $gp['white_label_plugin_name']) : '';
2720 3037 $logo_light = isset($wp['logo_light']) ? trim((string) $wp['logo_light']) : '';
2721 3038 $logo_dark = isset($wp['logo_dark']) ? trim((string) $wp['logo_dark']) : '';
2722 3039 $author = isset($gp['white_label_plugin_author']) ? trim((string) $gp['white_label_plugin_author']) : '';
@@ -2733,8 +3050,9 @@
2733 3050 }
2734 3051 }
2735 3052
2736 3053 if (!empty($validation_errors)) {
3054 + $this->force_release_settings_recovery_lock();
2737 3055 wp_send_json_error([
2738 3056 'errors' => $validation_errors
2739 3057 ]);
2740 3058 return;
@@ -2879,9 +3197,10 @@
2879 3197 }
2880 3198
2881 3199 if (isset($_POST['metasync_options']['whitelabel'])) {
2882 3200
2883 - $whitelabel_data = $_POST['metasync_options']['whitelabel'];
3201 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every stored key below is validated/sanitised (FILTER_VALIDATE_URL + esc_url_raw for logos, sanitize_* for text).
3202 + $whitelabel_data = wp_unslash($_POST['metasync_options']['whitelabel']);
2884 3203 $existing_whitelabel = $metasync_options['whitelabel'] ?? [];
2885 3204
2886 3205 if (isset($whitelabel_data['logo'])) {
2887 3206 $logo_value = trim($whitelabel_data['logo']);
@@ -2922,9 +3241,9 @@
2922 3241 }
2923 3242 }
2924 3243
2925 3244 if (isset($whitelabel_data['settings_password'])) {
2926 - $password_value = trim($whitelabel_data['settings_password']);
3245 + $password_value = (string) $whitelabel_data['settings_password'];
2927 3246
2928 3247 $hide_settings_enabled = isset($whitelabel_data['hide_settings']) && $whitelabel_data['hide_settings'] == '1';
2929 3248
2930 3249 if ($hide_settings_enabled && empty($password_value)) {
@@ -2930,10 +3249,18 @@
2930 3249 if ($hide_settings_enabled && empty($password_value)) {
2931 3250 if (isset($existing_whitelabel['settings_password'])) {
2932 3251 $metasync_options['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'];
2933 3252 }
3253 + } elseif ($password_value === '') {
3254 + $metasync_options['whitelabel']['settings_password'] = '';
2934 3255 } else {
2935 - $metasync_options['whitelabel']['settings_password'] = Metasync::encrypt_secret(sanitize_text_field($password_value));
3256 + $encrypted_password = Metasync::encrypt_secret($password_value);
3257 + if (Metasync::is_encrypted_secret($encrypted_password) && !hash_equals($password_value, $encrypted_password)) {
3258 + $metasync_options['whitelabel']['settings_password'] = $encrypted_password;
3259 + } else {
3260 + $metasync_options['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'] ?? '';
3261 + $whitelabel_errors['settings_password'] = 'Unable to securely save the password. Please choose a different password.';
3262 + }
2936 3263 }
2937 3264 } else {
2938 3265 if (isset($existing_whitelabel['settings_password'])) {
2939 3266 $metasync_options['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'];
@@ -3014,9 +3341,9 @@
3014 3341
3015 3342 }
3016 3343
3017 3344 $redirect_url = isset($_GET['tab']) ?
3018 - admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-settings&tab='.$_GET['tab']) :
3345 + admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-settings&tab=' . sanitize_text_field(wp_unslash($_GET['tab']))) :
3019 3346 admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-settings');
3020 3347
3021 3348 // Also save edge cache settings if present in the payload (section renders inside this form)
3022 3349 if (class_exists('Metasync_Edge_Cache_Settings')) {
@@ -3023,8 +3350,9 @@
3023 3350 Metasync_Edge_Cache_Settings::save_from_post();
3024 3351 }
3025 3352
3026 3353 if ($general_tab_submitted && isset($_POST['metasync_llms_txt_settings'])) {
3354 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitised by sanitize_llms_txt_settings() before storage.
3027 3355 $llms_input = wp_unslash($_POST['metasync_llms_txt_settings']);
3028 3356 if (is_array($llms_input)) {
3029 3357 update_option('metasync_llms_txt_settings', $this->sanitize_llms_txt_settings($llms_input));
3030 3358 }
@@ -3064,8 +3392,10 @@
3064 3392 }
3065 3393
3066 3394 delete_transient('metasync_otto_js_detected');
3067 3395
3396 + $this->force_release_settings_recovery_lock();
3397 +
3068 3398 wp_send_json_success( $response_payload );
3069 3399 }
3070 3400
3071 3401 /**
@@ -3074,9 +3404,10 @@
3074 3404 * Moved from Metasync_Admin – the admin class now delegates here.
3075 3405 */
3076 3406 public function ajax_save_execution_settings()
3077 3407 {
3078 - if (!isset($_POST['execution_settings_nonce']) || !wp_verify_nonce($_POST['execution_settings_nonce'], 'metasync_execution_settings_nonce')) {
3408 + $execution_nonce = isset($_POST['execution_settings_nonce']) ? sanitize_text_field(wp_unslash($_POST['execution_settings_nonce'])) : '';
3409 + if (!wp_verify_nonce($execution_nonce, 'metasync_execution_settings_nonce')) {
3079 3410 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
3080 3411 return;
3081 3412 }
3082 3413
@@ -3170,9 +3501,552 @@
3170 3501 wp_send_json_error(array('message' => 'Failed to save settings. Please try again.'));
3171 3502 }
3172 3503 }
3173 3504
3505 + # ------------------------------------------------------------------
3506 + # Headless Mode settings on the Advanced tab
3507 + # ------------------------------------------------------------------
3508 + # Metasync_Headless_Config is the only sanitiser for these four values.
3509 + # Nothing here validates a domain, a policy or a prefix — it collects
3510 + # what was submitted, hands it to update_settings(), and then shows back
3511 + # whatever that stored, so a value the sanitiser refused reads as refused
3512 + # instead of being echoed back as though it saved.
3513 + # ------------------------------------------------------------------
3514 +
3174 3515 /**
3516 + * Field prefix shared by every control in the Headless Mode block.
3517 + *
3518 + * The controls sit inside the Advanced tab's outer form, which posts to
3519 + * options.php. Naming them outside `metasync_options[...]` keeps them out
3520 + * of sanitize() entirely, so an unrelated save of that form can neither
3521 + * store them unsanitised nor clear them.
3522 + */
3523 + const HEADLESS_FIELD_PREFIX = 'metasync_headless_';
3524 +
3525 + /**
3526 + * Nonce action and request field for the Headless Mode save.
3527 + */
3528 + const HEADLESS_NONCE_ACTION = 'metasync_headless_settings_nonce';
3529 + const HEADLESS_NONCE_FIELD = 'headless_settings_nonce';
3530 +
3531 + /**
3532 + * Render the Headless Mode section of the Advanced settings accordion.
3533 + *
3534 + * Values come from Metasync_Headless_Config::get_settings(), which is the
3535 + * sanitised stored state rather than anything the operator typed.
3536 + *
3537 + * @return void
3538 + */
3539 + public function render_headless_mode_section()
3540 + {
3541 + $settings = Metasync_Headless_Config::get_settings();
3542 + $enabled = !empty($settings['enabled']);
3543 + $active = Metasync_Headless_Config::is_active();
3544 + $prefix = self::HEADLESS_FIELD_PREFIX;
3545 +
3546 + $policy_labels = array(
3547 + Metasync_Headless_Config::SLASH_PRESERVE => 'Preserve — hand back whatever WordPress produced (default)',
3548 + Metasync_Headless_Config::SLASH_STRIP => 'Strip — no trailing slash',
3549 + Metasync_Headless_Config::SLASH_ADD => 'Add — always end with a trailing slash',
3550 + );
3551 + ?>
3552 + <div style="background: var(--dashboard-card-bg); padding: 20px; border-radius: 8px;">
3553 + <p style="color: var(--dashboard-text-secondary); margin: 0 0 20px 0;">
3554 + Headless Mode describes a separate frontend that serves the public site, so this plugin can build
3555 + URLs on that host instead of this one. With the mode off nothing here changes how the site behaves.
3556 + </p>
3557 +
3558 + <div style="background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.3); border-radius: 8px; padding: 16px; margin-bottom: 20px;">
3559 + <p style="color: var(--dashboard-text-secondary); margin: 0;">
3560 + Headless Mode is for sites where WordPress manages content and a separate frontend serves the public pages.
3561 + Set the public frontend URL below, then choose how MetaSync should build those public URLs.
3562 + </p>
3563 + </div>
3564 +
3565 + <div id="metasync-headless-settings-message" style="display: none; margin-bottom: 20px;"></div>
3566 +
3567 + <form id="metasync-headless-settings-form" method="post" data-headless-active="<?php echo $active ? '1' : '0'; ?>">
3568 + <?php wp_nonce_field(self::HEADLESS_NONCE_ACTION, self::HEADLESS_NONCE_FIELD); ?>
3569 +
3570 + <div style="background: var(--dashboard-card-bg-alt, rgba(255,255,255,0.05)); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
3571 + <label for="<?php echo esc_attr($prefix . 'enabled'); ?>" style="display: flex; align-items: center; gap: 10px; color: var(--dashboard-text-primary); font-weight: 500; cursor: pointer;">
3572 + <?php
3573 + # Hidden field first, checkbox second, same name. An unchecked
3574 + # checkbox posts nothing at all, and normalize() reads an absent
3575 + # `enabled` key as false — so without this pair, a submission that
3576 + # simply did not include the checkbox would switch the mode off as
3577 + # a side effect. With it, "off" arrives as an explicit 0.
3578 + ?>
3579 + <input type="hidden" name="<?php echo esc_attr($prefix . 'enabled'); ?>" value="0" />
3580 + <input type="checkbox"
3581 + id="<?php echo esc_attr($prefix . 'enabled'); ?>"
3582 + name="<?php echo esc_attr($prefix . 'enabled'); ?>"
3583 + value="1"
3584 + style="width: 18px; height: 18px; cursor: pointer;"
3585 + <?php checked($enabled, true); ?> />
3586 + <span>Enable Headless Mode</span>
3587 + </label>
3588 +
3589 + <div style="background: rgba(245, 158, 11, 0.1); border: 1px solid rgba(245, 158, 11, 0.3); border-radius: 8px; padding: 16px; margin: 16px 0 0 0;">
3590 + <p style="color: var(--dashboard-text-secondary); margin: 0; font-size: 13px;">
3591 + <strong style="color: var(--dashboard-warning, #f59e0b);">Changing this can affect your public frontend.</strong><br />
3592 + When Headless Mode is active, the GraphQL SEO field and generated URLs follow the settings below.
3593 + Coordinate with whoever maintains the frontend before disabling it or changing its public URL.
3594 + </p>
3595 + </div>
3596 +
3597 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 12px 0 0 0;">
3598 + Current state:
3599 + <strong style="color: var(--dashboard-text-primary);"><?php echo $enabled ? 'enabled' : 'disabled'; ?></strong><?php
3600 + if ($enabled && !$active) {
3601 + ?> &mdash; but not yet in effect: a frontend domain is required before any headless URL can be built.<?php
3602 + }
3603 + ?>
3604 + </p>
3605 + </div>
3606 +
3607 + <div style="background: var(--dashboard-card-bg-alt, rgba(255,255,255,0.05)); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
3608 + <div style="margin-bottom: 24px;">
3609 + <label for="<?php echo esc_attr($prefix . 'frontend_domain'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px; font-weight: 500;">
3610 + Public frontend URL:
3611 + </label>
3612 + <input type="url"
3613 + id="<?php echo esc_attr($prefix . 'frontend_domain'); ?>"
3614 + name="<?php echo esc_attr($prefix . 'frontend_domain'); ?>"
3615 + value="<?php echo esc_attr($settings['frontend_domain']); ?>"
3616 + placeholder="https://www.example.com"
3617 + style="width: 100%; max-width: 420px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg); color: var(--dashboard-text-primary);" />
3618 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3619 + Scheme and host only &mdash; for example <code>https://www.example.com</code>. A path is
3620 + refused: <code>https://example.com/blog</code> will not save, because only per-post-type
3621 + prefixes exist and a shared path segment could never be put back on terms, archives or
3622 + the homepage. Use a path prefix below instead. A bare host is treated as https.
3623 + </p>
3624 + </div>
3625 +
3626 + <div style="margin-bottom: 24px;">
3627 + <label for="<?php echo esc_attr($prefix . 'slash_policy'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px; font-weight: 500;">
3628 + Trailing Slash Policy:
3629 + </label>
3630 + <select id="<?php echo esc_attr($prefix . 'slash_policy'); ?>"
3631 + name="<?php echo esc_attr($prefix . 'slash_policy'); ?>"
3632 + style="max-width: 420px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg); color: var(--dashboard-text-primary);">
3633 + <?php foreach (Metasync_Headless_Config::slash_policies() as $policy) { ?>
3634 + <option value="<?php echo esc_attr($policy); ?>" <?php selected($settings['slash_policy'], $policy); ?>>
3635 + <?php echo esc_html(isset($policy_labels[$policy]) ? $policy_labels[$policy] : $policy); ?>
3636 + </option>
3637 + <?php } ?>
3638 + </select>
3639 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3640 + Choose how generated public URLs end. For example, <code>/about</code> stays unchanged with Preserve,
3641 + becomes <code>/about</code> with Strip, or <code>/about/</code> with Add. Match your frontend router.
3642 + </p>
3643 + </div>
3644 +
3645 + <div>
3646 + <label for="<?php echo esc_attr($prefix . 'path_prefixes'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px; font-weight: 500;">
3647 + Path Prefixes:
3648 + </label>
3649 + <textarea id="<?php echo esc_attr($prefix . 'path_prefixes'); ?>"
3650 + name="<?php echo esc_attr($prefix . 'path_prefixes'); ?>"
3651 + rows="4"
3652 + placeholder="post = blog"
3653 + style="width: 100%; max-width: 420px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg); color: var(--dashboard-text-primary); font-family: monospace;"><?php echo esc_textarea(self::format_headless_path_prefixes($settings['path_prefixes'])); ?></textarea>
3654 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3655 + Map WordPress content types to frontend URL folders:
3656 + </p>
3657 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 8px 0 0 0;">
3658 + One per line, for example <code>post = blog</code> produces <code>/blog/example-post</code>.
3659 + Leave empty when your frontend uses the same paths as WordPress.
3660 + </p>
3661 + </div>
3662 +
3663 + <details style="margin-top: 24px;">
3664 + <summary style="color: var(--dashboard-text-primary); font-weight: 500; cursor: pointer;">Advanced refresh settings</summary>
3665 + <div style="padding-top: 16px;">
3666 + <label for="<?php echo esc_attr($prefix . 'refresh_interval_minutes'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px;">
3667 + Background refresh interval (minutes):
3668 + </label>
3669 + <input type="number"
3670 + id="<?php echo esc_attr($prefix . 'refresh_interval_minutes'); ?>"
3671 + name="<?php echo esc_attr($prefix . 'refresh_interval_minutes'); ?>"
3672 + value="<?php echo esc_attr($settings['refresh_interval_minutes']); ?>"
3673 + min="<?php echo esc_attr((string) Metasync_Headless_Config::REFRESH_INTERVAL_MIN_MINUTES); ?>"
3674 + max="<?php echo esc_attr((string) Metasync_Headless_Config::REFRESH_INTERVAL_MAX_MINUTES); ?>"
3675 + step="1"
3676 + style="width: 100%; max-width: 200px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg); color: var(--dashboard-text-primary);" />
3677 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3678 + How often the background job re-checks a URL's stored OTTO data against OTTO directly, for
3679 + any post or term OTTO's deployment webhook missed. Does not affect the webhook, which stays
3680 + the primary, immediate update path. Between
3681 + <?php echo esc_html((string) Metasync_Headless_Config::REFRESH_INTERVAL_MIN_MINUTES); ?> and
3682 + <?php echo esc_html((string) Metasync_Headless_Config::REFRESH_INTERVAL_MAX_MINUTES); ?> minutes;
3683 + defaults to <?php echo esc_html((string) Metasync_Headless_Config::REFRESH_INTERVAL_DEFAULT_MINUTES); ?>.
3684 + </p>
3685 + </div>
3686 + </details>
3687 + </div>
3688 +
3689 + <button type="button"
3690 + id="metasync-headless-settings-save-btn"
3691 + class="metasync-btn-primary"
3692 + style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 12px 24px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);">
3693 + <span class="save-text">Save Headless Settings</span>
3694 + </button>
3695 + </form>
3696 + </div>
3697 + <script>
3698 + jQuery(document).ready(function ($) {
3699 + // Click, not submit: this form is nested inside #metaSyncGeneralSetting,
3700 + // so the browser drops the inner form tag and a submit handler would
3701 + // never fire on its own.
3702 + var $saveBtn = $('#metasync-headless-settings-save-btn');
3703 + var $message = $('#metasync-headless-settings-message');
3704 + var headlessModeWasActive = <?php echo $active ? 'true' : 'false'; ?>;
3705 + var headlessDisableWarning = 'Turning off Headless Mode removes the metasyncSeo field from the GraphQL schema. Any frontend querying it will error until it is redeployed. Continue?';
3706 + var headlessDomainWarning = 'The metasyncSeo field stays in the GraphQL schema, but every URL it returns will be empty until a frontend domain is set again. Continue?';
3707 + var headlessModeSavedSettings = <?php echo wp_json_encode(array(
3708 + 'enabled' => !empty($settings['enabled']),
3709 + 'frontend_domain' => $settings['frontend_domain'],
3710 + 'slash_policy' => $settings['slash_policy'],
3711 + 'path_prefixes' => self::format_headless_path_prefixes($settings['path_prefixes']),
3712 + 'refresh_interval_minutes' => $settings['refresh_interval_minutes'],
3713 + )); ?>;
3714 +
3715 + // This mirrors normalize_domain()'s shape rules so a value the
3716 + // server will refuse cannot look active here. Keep this deliberately
3717 + // small: it is a confirmation decision, not a client-side validator,
3718 + // and the server remains authoritative. Mirrors can drift.
3719 + function isUsableFrontendDomain(value) {
3720 + // PHP's trim() strips only space, tab, newline, carriage return,
3721 + // NUL and vertical tab, while $.trim() and String.trim() strip the
3722 + // whole Unicode set. Trimming a non-breaking space or BOM that the
3723 + // server keeps would make a pasted domain look usable while
3724 + // normalize_domain() rejects it — the guard stays silent and the
3725 + // mode deactivates anyway. Match the server charlist exactly.
3726 + var candidate = (value || '').replace(/^[ \t\n\r\0\x0B]+|[ \t\n\r\0\x0B]+$/g, '');
3727 + var parsed;
3728 + var authority;
3729 + var host;
3730 + var path;
3731 +
3732 + if (candidate === '') {
3733 + return false;
3734 + }
3735 +
3736 + if (candidate.indexOf('//') === -1) {
3737 + candidate = 'https://' + candidate.replace(/^\/+/, '');
3738 + }
3739 +
3740 + parsed = candidate.match(/^(?:([a-z][a-z0-9+.-]*):)?\/\/([^/?#]*)([^?#]*)/i);
3741 + if (!parsed || (parsed[1] && parsed[1].toLowerCase() !== 'http' && parsed[1].toLowerCase() !== 'https')) {
3742 + return false;
3743 + }
3744 +
3745 + path = parsed[3] || '';
3746 + if (path.replace(/^\/+|\/+$/g, '') !== '') {
3747 + return false;
3748 + }
3749 +
3750 + authority = parsed[2].replace(/^.*@/, '');
3751 + if (authority.charAt(0) === '[') {
3752 + return /^\[[0-9a-f:.]+\](?::\d+)?$/i.test(authority);
3753 + }
3754 +
3755 + host = authority.replace(/:\d+$/, '');
3756 + if (!/^[\p{L}\p{N}._-]+$/u.test(host) || host.indexOf('..') !== -1) {
3757 + return false;
3758 + }
3759 +
3760 + return !/^[.-]|[.-]$/.test(host) && /[\p{L}\p{N}]/u.test(host);
3761 + }
3762 +
3763 + function showMessage(lines, type) {
3764 + var $list = $('<div/>');
3765 + $.each(lines, function (i, line) {
3766 + $list.append($('<p/>').css('margin', i === 0 ? '0' : '8px 0 0 0').text(line));
3767 + });
3768 +
3769 + $message
3770 + .css({
3771 + 'background': type === 'success' ? 'rgba(34, 197, 94, 0.1)' : 'rgba(239, 68, 68, 0.1)',
3772 + 'border': '1px solid ' + (type === 'success' ? 'rgba(34, 197, 94, 0.3)' : 'rgba(239, 68, 68, 0.3)'),
3773 + 'color': type === 'success' ? '#22c55e' : '#ef4444',
3774 + 'padding': '12px 16px',
3775 + 'border-radius': '6px',
3776 + 'font-size': '14px',
3777 + 'line-height': '1.5'
3778 + })
3779 + .empty()
3780 + .append($list)
3781 + .show();
3782 + }
3783 +
3784 + // Repaint from what was actually stored, never from what was typed, so a
3785 + // refused value reads as refused rather than looking saved.
3786 + function applyStoredSettings(settings) {
3787 + if (!settings) {
3788 + return;
3789 + }
3790 + headlessModeSavedSettings = $.extend({}, settings);
3791 + headlessModeWasActive = !!settings.enabled && isUsableFrontendDomain(settings.frontend_domain);
3792 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>').prop('checked', !!settings.enabled);
3793 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>').val(settings.frontend_domain);
3794 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'slash_policy'); ?>').val(settings.slash_policy);
3795 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'path_prefixes'); ?>').val(settings.path_prefixes);
3796 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'refresh_interval_minutes'); ?>').val(settings.refresh_interval_minutes);
3797 + }
3798 +
3799 + $saveBtn.on('click', function (e) {
3800 + e.preventDefault();
3801 +
3802 + var data = {
3803 + action: 'metasync_save_headless_settings',
3804 + <?php echo esc_js(self::HEADLESS_NONCE_FIELD); ?>: $('#<?php echo esc_js(self::HEADLESS_NONCE_FIELD); ?>').val(),
3805 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>').is(':checked') ? '1' : '0',
3806 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>').val(),
3807 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'slash_policy'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'slash_policy'); ?>').val(),
3808 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'path_prefixes'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'path_prefixes'); ?>').val(),
3809 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'refresh_interval_minutes'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'refresh_interval_minutes'); ?>').val()
3810 + };
3811 +
3812 + var submittedModeIsEnabled = data['<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>'] === '1';
3813 + var submittedDomain = data['<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>'];
3814 + var submittedModeIsActive = submittedModeIsEnabled
3815 + && isUsableFrontendDomain(submittedDomain);
3816 +
3817 + if (submittedModeIsEnabled && !submittedModeIsActive) {
3818 + showMessage(['Enter a valid public frontend URL before enabling Headless Mode. Use a scheme and host only, for example https://www.example.com.'], 'error');
3819 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>').trigger('focus');
3820 + return;
3821 + }
3822 +
3823 + var confirmationMessage = submittedModeIsEnabled ? headlessDomainWarning : headlessDisableWarning;
3824 +
3825 + if (headlessModeWasActive && !submittedModeIsActive && !window.confirm(confirmationMessage)) {
3826 + applyStoredSettings(headlessModeSavedSettings);
3827 + return;
3828 + }
3829 +
3830 + $saveBtn.prop('disabled', true);
3831 + $saveBtn.find('.save-text').text('Saving...');
3832 + $message.hide();
3833 +
3834 + $.ajax({
3835 + url: window.ajaxurl,
3836 + type: 'POST',
3837 + data: data,
3838 + success: function (response) {
3839 + if (response && response.success) {
3840 + applyStoredSettings(response.data.settings);
3841 + var lines = [response.data.message];
3842 + if (response.data.notes && response.data.notes.length) {
3843 + lines = lines.concat(response.data.notes);
3844 + }
3845 + showMessage(lines, response.data.notes && response.data.notes.length ? 'error' : 'success');
3846 + } else {
3847 + showMessage([(response && response.data && response.data.message) || 'Error saving Headless Mode settings.'], 'error');
3848 + }
3849 + $saveBtn.prop('disabled', false);
3850 + $saveBtn.find('.save-text').text('Save Headless Settings');
3851 + },
3852 + error: function () {
3853 + showMessage(['An error occurred while saving Headless Mode settings. Please try again.'], 'error');
3854 + $saveBtn.prop('disabled', false);
3855 + $saveBtn.find('.save-text').text('Save Headless Settings');
3856 + }
3857 + });
3858 + });
3859 + });
3860 + </script>
3861 + <?php
3862 + }
3863 +
3864 + /**
3865 + * Render the stored post-type => prefix map back into the textarea format.
3866 + *
3867 + * @param mixed $prefixes Stored prefix map.
3868 + * @return string One `post_type = prefix` entry per line.
3869 + */
3870 + public static function format_headless_path_prefixes($prefixes)
3871 + {
3872 + if (!is_array($prefixes)) {
3873 + return '';
3874 + }
3875 +
3876 + $lines = array();
3877 +
3878 + foreach ($prefixes as $post_type => $prefix) {
3879 + $lines[] = $post_type . ' = ' . $prefix;
3880 + }
3881 +
3882 + return implode("\n", $lines);
3883 + }
3884 +
3885 + /**
3886 + * Read the textarea format back into a post-type => prefix map.
3887 + *
3888 + * One entry per line, `post_type = prefix`. A plain text format is enough
3889 + * for a demo and needs no repeater UI. A line with no `=` names no post
3890 + * type, so it is skipped rather than guessed at; everything that does
3891 + * survive is still handed to Metasync_Headless_Config::normalize(), which
3892 + * is what actually decides whether a post type and prefix are usable.
3893 + *
3894 + * @param mixed $text Raw textarea contents.
3895 + * @return array<string,string> Unsanitised map, keyed by post type.
3896 + */
3897 + public static function parse_headless_path_prefixes($text)
3898 + {
3899 + $map = array();
3900 +
3901 + if (!is_string($text)) {
3902 + return $map;
3903 + }
3904 +
3905 + foreach (preg_split('/\R/', $text) as $line) {
3906 + if (strpos($line, '=') === false) {
3907 + continue;
3908 + }
3909 +
3910 + list($post_type, $prefix) = explode('=', $line, 2);
3911 +
3912 + $post_type = trim($post_type);
3913 + if ($post_type === '') {
3914 + continue;
3915 + }
3916 +
3917 + $map[$post_type] = trim($prefix);
3918 + }
3919 +
3920 + return $map;
3921 + }
3922 +
3923 + /**
3924 + * Merge a Headless Mode submission over the stored settings and persist it.
3925 + *
3926 + * Additive on purpose: a key the request did not carry keeps whatever is
3927 + * already stored. normalize() treats an absent `enabled` as false, so a
3928 + * submission that omitted the checkbox would otherwise switch the mode off
3929 + * as a side effect of saving something else. The hidden field in front of
3930 + * the checkbox is the other half of that guard — it makes "off" an explicit
3931 + * `0` rather than an absence, so unticking the box still works.
3932 + *
3933 + * Sanitising is left entirely to Metasync_Headless_Config.
3934 + *
3935 + * @param mixed $submitted Raw request data, normally $_POST.
3936 + * @return array The settings that were actually stored.
3937 + */
3938 + public function save_headless_settings($submitted)
3939 + {
3940 + if (!is_array($submitted)) {
3941 + $submitted = array();
3942 + }
3943 +
3944 + $prefix = self::HEADLESS_FIELD_PREFIX;
3945 + $input = Metasync_Headless_Config::get_settings();
3946 +
3947 + if (isset($submitted[$prefix . 'enabled'])) {
3948 + $input['enabled'] = $submitted[$prefix . 'enabled'];
3949 + }
3950 +
3951 + if (isset($submitted[$prefix . 'frontend_domain'])) {
3952 + $input['frontend_domain'] = wp_unslash($submitted[$prefix . 'frontend_domain']);
3953 + }
3954 +
3955 + if (isset($submitted[$prefix . 'slash_policy'])) {
3956 + $input['slash_policy'] = $submitted[$prefix . 'slash_policy'];
3957 + }
3958 +
3959 + if (isset($submitted[$prefix . 'path_prefixes'])) {
3960 + $input['path_prefixes'] = self::parse_headless_path_prefixes(
3961 + wp_unslash($submitted[$prefix . 'path_prefixes'])
3962 + );
3963 + }
3964 +
3965 + if (isset($submitted[$prefix . 'refresh_interval_minutes'])) {
3966 + $input['refresh_interval_minutes'] = $submitted[$prefix . 'refresh_interval_minutes'];
3967 + }
3968 +
3969 + return Metasync_Headless_Config::update_settings($input);
3970 + }
3971 +
3972 + /**
3973 + * AJAX handler for the Headless Mode block on the Advanced tab.
3974 + *
3975 + * Same nonce-then-capability shape as the other Advanced tab handlers.
3976 + *
3977 + * @return void
3978 + */
3979 + /**
3980 + * Did the last write actually land?
3981 + *
3982 + * update_settings() hands back what it meant to store, not proof that it
3983 + * reached the database: a full disk, a failing object cache or a read-only
3984 + * replica all produce a silent no-op. Telling an operator their headless
3985 + * settings saved when they did not is worse than telling them nothing —
3986 + * they would go on to wonder why the frontend sees none of it.
3987 + *
3988 + * @param array $intended What the save call reported storing.
3989 + * @return bool
3990 + */
3991 + private function headless_settings_persisted(array $intended)
3992 + {
3993 + Metasync_Headless_Config::flush();
3994 +
3995 + return Metasync_Headless_Config::get_settings() == $intended;
3996 + }
3997 +
3998 + public function ajax_save_headless_settings()
3999 + {
4000 + $prefix = self::HEADLESS_FIELD_PREFIX;
4001 +
4002 + $headless_nonce = isset($_POST[self::HEADLESS_NONCE_FIELD]) ? sanitize_text_field(wp_unslash($_POST[self::HEADLESS_NONCE_FIELD])) : '';
4003 + if (!wp_verify_nonce($headless_nonce, self::HEADLESS_NONCE_ACTION)) {
4004 + wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
4005 + } elseif (!Metasync::current_user_has_plugin_access()) {
4006 + wp_send_json_error(array('message' => 'Insufficient permissions to save settings.'));
4007 + } else {
4008 + // phpcs:disable WordPress.Security.ValidatedSanitizedInput -- sanitised via Metasync_Headless_Config::normalize_domain() inside save_headless_settings().
4009 + $submitted_domain = isset($_POST[$prefix . 'frontend_domain'])
4010 + ? trim((string) wp_unslash($_POST[$prefix . 'frontend_domain']))
4011 + : '';
4012 + // phpcs:enable WordPress.Security.ValidatedSanitizedInput
4013 +
4014 + $stored = $this->save_headless_settings($_POST);
4015 +
4016 + if (!$this->headless_settings_persisted($stored)) {
4017 + wp_send_json_error(array(
4018 + 'message' => 'Those settings could not be saved. Nothing has changed — please try again, and check the site error log if it keeps happening.',
4019 + ));
4020 + }
4021 +
4022 + # Say so when the sanitiser refused something, rather than leaving the
4023 + # operator to spot that a field came back empty.
4024 + $notes = array();
4025 +
4026 + if ($submitted_domain !== '' && $stored['frontend_domain'] === '') {
4027 + $notes[] = 'That frontend domain was not saved. Give a scheme and host only, with no path — for example https://www.example.com.';
4028 + }
4029 +
4030 + if (!empty($stored['enabled']) && $stored['frontend_domain'] === '') {
4031 + $notes[] = 'Headless Mode is switched on but has no usable frontend domain, so nothing behaves as headless yet.';
4032 + }
4033 +
4034 + wp_send_json_success(array(
4035 + 'message' => 'Headless Mode settings saved.',
4036 + 'notes' => $notes,
4037 + 'settings' => array(
4038 + 'enabled' => !empty($stored['enabled']),
4039 + 'frontend_domain' => $stored['frontend_domain'],
4040 + 'slash_policy' => $stored['slash_policy'],
4041 + 'path_prefixes' => self::format_headless_path_prefixes($stored['path_prefixes']),
4042 + 'refresh_interval_minutes' => $stored['refresh_interval_minutes'],
4043 + ),
4044 + ));
4045 + }
4046 + }
4047 +
4048 + /**
3175 4049 * AJAX handler for saving SEO / Indexation Control settings.
3176 4050 *
3177 4051 * Moved from Metasync_Admin – the admin class now delegates here.
3178 4052 */
@@ -3178,13 +4052,26 @@
3178 4052 */
3179 4053 public function meta_sync_save_seo_controls()
3180 4054 {
3181 4055 try {
3182 - if (!isset($_POST['meta_sync_seo_controls_nonce']) || !wp_verify_nonce($_POST['meta_sync_seo_controls_nonce'], 'meta_sync_seo_controls_nonce')) {
4056 + $seo_controls_nonce = isset($_POST['meta_sync_seo_controls_nonce']) ? sanitize_text_field(wp_unslash($_POST['meta_sync_seo_controls_nonce'])) : '';
4057 + if (!wp_verify_nonce($seo_controls_nonce, 'meta_sync_seo_controls_nonce')) {
3183 4058 wp_send_json_error(array('message' => 'Invalid nonce'));
3184 4059 return;
3185 4060 }
3186 4061
4062 + // Same nonce-then-capability shape as the other settings save
4063 + // handlers: the nonce is not authorization, so a logged-in user
4064 + // without plugin access must still be refused here.
4065 + if (!Metasync::current_user_has_plugin_access()) {
4066 + wp_send_json_error(array('message' => 'Insufficient permissions.'), 403);
4067 + // The return is unreachable in WordPress (wp_send_json_error
4068 + // terminates the request) but keeps the handler safe under the
4069 + // non-terminating stubs the unit tests run with.
4070 + // @phpstan-ignore-next-line deadCode.unreachable
4071 + return;
4072 + }
4073 +
3187 4074 $current_options = Metasync::get_option();
3188 4075
3189 4076 $original_options = json_decode(json_encode($current_options), true);
3190 4077
@@ -3250,10 +4137,9 @@
3250 4137 }
3251 4138 wp_send_json_error(array('message' => $error_message));
3252 4139 }
3253 4140 } catch (Exception $e) {
3254 - error_log('Indexation Control AJAX Exception: ' . $e->getMessage());
3255 - error_log('Indexation Control AJAX Stack trace: ' . $e->getTraceAsString());
4141 + error_log('Indexation Control AJAX Exception: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3256 4142 wp_send_json_error(array('message' => 'Error saving settings: ' . $e->getMessage()));
3257 4143 }
3258 4144 }
3259 4145
@@ -3265,12 +4151,12 @@
3265 4151 * @return bool True on success, false on failure
3266 4152 */
3267 4153 public function save_bing_inline_settings_ajax()
3268 4154 {
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;
4155 + $api_key = isset($_POST['metasync_bing_api_key_inline']) ? sanitize_text_field(wp_unslash($_POST['metasync_bing_api_key_inline'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller verifies the SEO controls nonce first
4156 + $endpoint = isset($_POST['metasync_bing_endpoint_inline']) ? sanitize_text_field(wp_unslash($_POST['metasync_bing_endpoint_inline'])) : 'indexnow'; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller verifies the SEO controls nonce first
4157 + $post_types = isset($_POST['metasync_bing_post_types_inline']) && is_array($_POST['metasync_bing_post_types_inline']) ? array_map('sanitize_title', wp_unslash($_POST['metasync_bing_post_types_inline'])) : []; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller verifies the SEO controls nonce first
4158 + $disable_other_plugins = isset($_POST['metasync_bing_disable_other_plugins_inline']) ? true : false; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller verifies the SEO controls nonce first
3273 4159
3274 4160 $existing_settings = get_option('metasync_options_bing_instant_indexing', []);
3275 4161
3276 4162 $new_settings = [
@@ -3294,9 +4180,9 @@
3294 4180 $safe_key = sanitize_file_name( $api_key );
3295 4181 $file_path = ABSPATH . $safe_key . '.txt';
3296 4182 $real_path = realpath( ABSPATH );
3297 4183 if ( false === $real_path || 0 !== strpos( realpath( dirname( $file_path ) ), $real_path ) ) {
3298 - error_log( 'Bing IndexNow: Refusing to write outside ABSPATH' );
4184 + error_log( 'Bing IndexNow: Refusing to write outside ABSPATH' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3299 4185 return false;
3300 4186 }
3301 4187 // serve the key virtually via template_redirect instead of
3302 4188 // writing a physical {key}.txt. An on-disk file is served statically
@@ -3309,9 +4195,9 @@
3309 4195 }
3310 4196 Metasync_Bing_Instant_Index::register_virtual_key($safe_key);
3311 4197
3312 4198 if (file_exists($file_path)) {
3313 - @unlink($file_path);
4199 + @wp_delete_file($file_path);
3314 4200 }
3315 4201
3316 4202 $current_options = Metasync::get_option();
3317 4203 if (!isset($current_options['seo_controls']['enable_binginstantindex']) || $current_options['seo_controls']['enable_binginstantindex'] !== 'true') {