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