PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | includes/class-metasync-settings-registration.php +1557 -446 2.6.7 → 2.7.1 View file →
@@ -14,8 +14,23 @@
14 14 * @subpackage Metasync/includes
15 15 */
16 16 class Metasync_Settings_Registration
17 17 {
18 + private static $options_generation_at_sanitize = null;
19 + private static $allow_recovery_password_write = false;
20 +
21 + public static function authorize_recovery_password_write($allow = true)
22 + {
23 + self::$allow_recovery_password_write = (bool) $allow;
24 + }
25 + private static $settings_recovery_lock_owner = '';
26 + private static $abort_locked_settings_update = false;
27 + private static $defer_settings_recovery_lock_release = false;
28 +
29 + public static function capture_options_generation()
30 + {
31 + self::$options_generation_at_sanitize = (int) get_option('metasync_pw_reset_generation', 0);
32 + }
18 33 /** @var self|null */
19 34 private static $instance = null;
20 35
21 36 /**
@@ -30,10 +45,98 @@
30 45 }
31 46 return self::$instance;
32 47 }
33 48
34 - private function __construct() {}
49 + private function __construct() {
50 + add_filter('pre_update_option_metasync_options', array($this, 'protect_recovery_password_update'), 10, 3);
51 + add_action('updated_option_metasync_options', array($this, 'release_settings_recovery_lock'), PHP_INT_MAX, 0);
52 + add_action('wp_ajax_metasync_save_headless_settings', array($this, 'ajax_save_headless_settings'));
53 + }
35 54
55 + private function acquire_settings_recovery_lock()
56 + {
57 + if (self::$settings_recovery_lock_owner !== '') {
58 + self::$abort_locked_settings_update = false;
59 + Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
60 + return true;
61 + }
62 +
63 + $owner = '';
64 + if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($owner)) {
65 + self::$abort_locked_settings_update = true;
66 + return false;
67 + }
68 +
69 + self::$settings_recovery_lock_owner = $owner;
70 + self::$abort_locked_settings_update = false;
71 + register_shutdown_function(array($this, 'force_release_settings_recovery_lock'));
72 + Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
73 + return true;
74 + }
75 +
76 + public function release_settings_recovery_lock()
77 + {
78 + if (self::$defer_settings_recovery_lock_release) {
79 + return;
80 + }
81 + if (self::$settings_recovery_lock_owner === '') {
82 + return;
83 + }
84 +
85 + Metasync_Admin_Ajax::instance()->release_recovery_lock(self::$settings_recovery_lock_owner);
86 + self::$settings_recovery_lock_owner = '';
87 + self::$options_generation_at_sanitize = null;
88 + self::$allow_recovery_password_write = false;
89 + }
90 +
91 + public function force_release_settings_recovery_lock()
92 + {
93 + self::$defer_settings_recovery_lock_release = false;
94 + $this->release_settings_recovery_lock();
95 + }
96 +
97 + public function protect_recovery_password_update($new_value, $old_value, $option)
98 + {
99 + if (self::$allow_recovery_password_write) {
100 + return $new_value;
101 + }
102 + if (self::$options_generation_at_sanitize === null && is_array($new_value) && is_array($old_value)
103 + && isset($new_value['whitelabel']) && is_array($new_value['whitelabel'])
104 + && array_key_exists('settings_password', $new_value['whitelabel'])) {
105 + if (!$this->acquire_settings_recovery_lock()) {
106 + self::$abort_locked_settings_update = false;
107 + return $old_value;
108 + }
109 + Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
110 + $fresh = Metasync::get_option('whitelabel');
111 + if (is_array($fresh) && array_key_exists('settings_password', $fresh)) {
112 + $proposed = (string) $new_value['whitelabel']['settings_password'];
113 + $fresh_value = (string) $fresh['settings_password'];
114 + if (Metasync::is_encrypted_secret($proposed) && !Metasync::is_encrypted_secret($fresh_value)
115 + && Metasync::decrypt_secret($proposed) === $fresh_value) {
116 + return $new_value;
117 + }
118 + $new_value['whitelabel']['settings_password'] = $fresh['settings_password'];
119 + }
120 + }
121 + if (self::$abort_locked_settings_update) {
122 + self::$abort_locked_settings_update = false;
123 + return $old_value;
124 + }
125 + if (self::$options_generation_at_sanitize === null || !is_array($new_value) || !is_array($old_value)) return $new_value;
126 + $current_generation = (int) get_option('metasync_pw_reset_generation', 0);
127 + if ($current_generation !== (int) self::$options_generation_at_sanitize
128 + && isset($new_value['whitelabel'])
129 + && is_array($new_value['whitelabel'])
130 + && array_key_exists('settings_password', $new_value['whitelabel'])) {
131 + // Returning the same value update_option() already considers old
132 + // prevents any database write, even if this request's option cache
133 + // predates the completed recovery request.
134 + return $old_value;
135 + }
136 + return $new_value;
137 + }
138 +
36 139 /**
37 140 * Helper – replicate Metasync_Admin::get_effective_menu_title() without
38 141 * needing an admin instance.
39 142 */
@@ -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;
@@ -209,9 +343,9 @@
209 343 $value = isset($settings['max_posts']) ? (int) $settings['max_posts'] : 50;
210 344 echo '<div class="llms-txt-dependent">';
211 345 printf(
212 346 '<input type="number" id="llms_txt_max_posts" name="metasync_llms_txt_settings[max_posts]" min="1" max="500" value="%d" />',
213 - $value
347 + absint($value)
214 348 );
215 349 echo '</div>';
216 350 },
217 351 $page_slug . '_general',
@@ -282,9 +416,9 @@
282 416 $value = isset($settings['max_posts_full']) ? (int) $settings['max_posts_full'] : 25;
283 417 echo '<div class="llms-full-dependent">';
284 418 printf(
285 419 '<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
420 + absint($value)
287 421 );
288 422 echo '<p class="description">' . esc_html__('Maximum posts for /llms-full.txt (lower than summary to protect server performance).', 'metasync') . '</p>';
289 423 echo '</div>';
290 424 },
@@ -320,12 +454,12 @@
320 454 'Disallow Empty Archives',
321 455 function() use ($option_key) {
322 456 $noindex_empty_archives = Metasync::get_option('seo_controls')['noindex_empty_archives'] ?? 'false';
323 457 printf(
324 - '<input type="checkbox" id="noindex_empty_archives" name="' . $option_key . '[seo_controls][noindex_empty_archives]" value="true" %s />',
458 + '<input type="checkbox" id="noindex_empty_archives" name="' . esc_attr($option_key) . '[seo_controls][noindex_empty_archives]" value="true" %s />',
325 459 isset($noindex_empty_archives) && $noindex_empty_archives == 'true' ? 'checked' : ''
326 460 );
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>');
461 + echo '<span class="description"><strong>Empty Archives:</strong> When checked, automatically adds noindex to category, tag, author, and format archive pages that have no posts. Once posts are added to these archives, they will automatically be allowed for indexing. This prevents thin content pages from being indexed.</span>';
328 462 },
329 463 $page_slug . '_seo-controls',
330 464 $SECTION_SEO_CONTROLS
331 465 );
@@ -335,12 +469,12 @@
335 469 'Disallow Date Archives',
336 470 function() use ($option_key) {
337 471 $index_date_archives = Metasync::get_option('seo_controls')['index_date_archives'] ?? 'false';
338 472 printf(
339 - '<input type="checkbox" id="index_date_archives" name="' . $option_key . '[seo_controls][index_date_archives]" value="true" %s />',
473 + '<input type="checkbox" id="index_date_archives" name="' . esc_attr($option_key) . '[seo_controls][index_date_archives]" value="true" %s />',
340 474 isset($index_date_archives) && $index_date_archives == 'true' ? 'checked' : ''
341 475 );
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>');
476 + echo '<span class="description"><strong>Date Archives:</strong> When checked, prevents search engines from indexing date-based archive pages (e.g., /2024/01/, /2024/01/15/). These pages often have thin content and can dilute your site\'s SEO value. Recommended for most sites.</span>';
343 477 },
344 478 $page_slug . '_seo-controls',
345 479 $SECTION_SEO_CONTROLS
346 480 );
@@ -350,12 +484,12 @@
350 484 'Disallow Tag Archives',
351 485 function() use ($option_key) {
352 486 $index_tag_archives = Metasync::get_option('seo_controls')['index_tag_archives'] ?? 'false';
353 487 printf(
354 - '<input type="checkbox" id="index_tag_archives" name="' . $option_key . '[seo_controls][index_tag_archives]" value="true" %s />',
488 + '<input type="checkbox" id="index_tag_archives" name="' . esc_attr($option_key) . '[seo_controls][index_tag_archives]" value="true" %s />',
355 489 isset($index_tag_archives) && $index_tag_archives == 'true' ? 'checked' : ''
356 490 );
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>');
491 + echo '<span class="description"><strong>Tag Archives:</strong> When checked, prevents search engines from indexing tag archive pages (e.g., /tag/technology/). Useful if you have many low-quality tag pages or want to focus on category-based organization instead.</span>';
358 492 },
359 493 $page_slug . '_seo-controls',
360 494 $SECTION_SEO_CONTROLS
361 495 );
@@ -365,12 +499,12 @@
365 499 'Disallow Author Archives',
366 500 function() use ($option_key) {
367 501 $index_author_archives = Metasync::get_option('seo_controls')['index_author_archives'] ?? 'false';
368 502 printf(
369 - '<input type="checkbox" id="index_author_archives" name="' . $option_key . '[seo_controls][index_author_archives]" value="true" %s />',
503 + '<input type="checkbox" id="index_author_archives" name="' . esc_attr($option_key) . '[seo_controls][index_author_archives]" value="true" %s />',
370 504 isset($index_author_archives) && $index_author_archives == 'true' ? 'checked' : ''
371 505 );
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>');
506 + echo '<span class="description"><strong>Author Archives:</strong> When checked, prevents search engines from indexing author archive pages (e.g., /author/john-doe/). Recommended for single-author sites or when author pages don\'t provide unique value. Multi-author sites may want to keep this unchecked.</span>';
373 507 },
374 508 $page_slug . '_seo-controls',
375 509 $SECTION_SEO_CONTROLS
376 510 );
@@ -380,12 +514,12 @@
380 514 'Disallow Format Archives',
381 515 function() use ($option_key) {
382 516 $index_format_archives = Metasync::get_option('seo_controls')['index_format_archives'] ?? 'false';
383 517 printf(
384 - '<input type="checkbox" id="index_format_archives" name="' . $option_key . '[seo_controls][index_format_archives]" value="true" %s />',
518 + '<input type="checkbox" id="index_format_archives" name="' . esc_attr($option_key) . '[seo_controls][index_format_archives]" value="true" %s />',
385 519 isset($index_format_archives) && $index_format_archives == 'true' ? 'checked' : ''
386 520 );
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>');
521 + echo '<span class="description"><strong>Format Archives:</strong> When checked, prevents search engines from indexing post format archive pages (e.g., /type/aside/, /type/gallery/). These are rarely useful for SEO and can create duplicate content issues. Recommended for most sites.</span>';
388 522 },
389 523 $page_slug . '_seo-controls',
390 524 $SECTION_SEO_CONTROLS
391 525 );
@@ -395,12 +529,12 @@
395 529 'Disallow Category Archives',
396 530 function() use ($option_key) {
397 531 $index_category_archives = Metasync::get_option('seo_controls')['index_category_archives'] ?? 'false';
398 532 printf(
399 - '<input type="checkbox" id="index_category_archives" name="' . $option_key . '[seo_controls][index_category_archives]" value="true" %s />',
533 + '<input type="checkbox" id="index_category_archives" name="' . esc_attr($option_key) . '[seo_controls][index_category_archives]" value="true" %s />',
400 534 isset($index_category_archives) && $index_category_archives == 'true' ? 'checked' : ''
401 535 );
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>');
536 + echo '<span class="description"><strong>Category Archives:</strong> When checked, prevents search engines from indexing category archive pages (e.g., /category/news/). This may be useful if your category pages have thin content or if you want to consolidate SEO value on main pages instead. Use with caution as category pages can be valuable for site organization.</span>';
403 537 },
404 538 $page_slug . '_seo-controls',
405 539 $SECTION_SEO_CONTROLS
406 540 );
@@ -410,12 +544,12 @@
410 544 'Add No-follow to External Links',
411 545 function() use ($option_key) {
412 546 $add_nofollow_to_external_links = Metasync::get_option('seo_controls')['add_nofollow_to_external_links'] ?? 'false';
413 547 printf(
414 - '<input type="checkbox" id="add_nofollow_to_external_links" name="' . $option_key . '[seo_controls][add_nofollow_to_external_links]" value="true" %s />',
548 + '<input type="checkbox" id="add_nofollow_to_external_links" name="' . esc_attr($option_key) . '[seo_controls][add_nofollow_to_external_links]" value="true" %s />',
415 549 isset($add_nofollow_to_external_links) && $add_nofollow_to_external_links == 'true' ? 'checked' : ''
416 550 );
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>');
551 + printf('<span class="description"><strong>No-follow External Links:</strong> When checked, automatically adds <code>rel="nofollow"</code> attribute to all external links appearing in posts, pages, and other post types when rendered by %s. This tells search engines not to follow these links, which can help preserve your site\'s SEO value and prevent passing link juice to external sites.</span>', esc_html(Metasync::get_whitelabel_otto_name()));
418 552 },
419 553 $page_slug . '_seo-controls',
420 554 $SECTION_SEO_CONTROLS
421 555 );
@@ -438,12 +572,12 @@
438 572 'Override Other Plugins\' Robots Tags',
439 573 function() use ($option_key) {
440 574 $override_robots_tags = Metasync::get_option('seo_controls')['override_robots_tags'] ?? 'false';
441 575 printf(
442 - '<input type="checkbox" id="override_robots_tags" name="' . $option_key . '[seo_controls][override_robots_tags]" value="true" %s />',
576 + '<input type="checkbox" id="override_robots_tags" name="' . esc_attr($option_key) . '[seo_controls][override_robots_tags]" value="true" %s />',
443 577 isset($override_robots_tags) && $override_robots_tags == 'true' ? 'checked' : ''
444 578 );
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>');
579 + echo '<span class="description"><strong>Override Robots Tags:</strong> When checked, ' . esc_html(Metasync_Settings_Registration::instance()->get_effective_menu_title()) . ' will take precedence over robots meta tags from other SEO plugins (Yoast, Rank Math, All in One SEO, etc.). This removes their noindex tags when you want to allow indexing on archive pages. Only enable this if other plugins are conflicting with your indexation settings.</span>';
446 580 },
447 581 $page_slug . '_seo-controls',
448 582 $SECTION_SEO_CONTROLS_ADVANCED
449 583 );
@@ -466,12 +600,13 @@
466 600 'Enable Google Instant Indexing',
467 601 function() use ($option_key) {
468 602 $enable_googleinstantindex = Metasync::get_option('seo_controls')['enable_googleinstantindex'] ?? 'false';
469 603 printf(
470 - '<input type="checkbox" id="enable_googleinstantindex" name="' . $option_key . '[seo_controls][enable_googleinstantindex]" value="true" %s />',
604 + '<input type="checkbox" id="enable_googleinstantindex" name="' . esc_attr($option_key) . '[seo_controls][enable_googleinstantindex]" value="true" %s />',
471 605 isset($enable_googleinstantindex) && $enable_googleinstantindex == 'true' ? 'checked' : ''
472 606 );
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>');
607 + Metasync::render_tooltip_icon('enable_googleinstantindex', 'Turns on direct URL submission to Google. It only works after you add a service-account key below and verify your site in Search Console — set those up first.');
608 + echo '<span class="description" style="margin-left:8px;"><strong>Enable Instant Indexing:</strong> When checked, enables the Google Instant Indexing feature which allows you to submit URLs directly to Google for faster indexing. Configure it in the Google Instant Indexing section below.</span>';
474 609 },
475 610 $page_slug . '_seo-controls',
476 611 $SECTION_SEO_CONTROLS_INSTANT_INDEX
477 612 );
@@ -512,13 +647,13 @@
512 647 $enable_binginstantindex = 'true';
513 648 }
514 649
515 650 printf(
516 - '<input type="checkbox" id="enable_binginstantindex" name="' . $option_key . '[seo_controls][enable_binginstantindex]" value="true" %s %s />',
651 + '<input type="checkbox" id="enable_binginstantindex" name="' . esc_attr($option_key) . '[seo_controls][enable_binginstantindex]" value="true" %s %s />',
517 652 isset($enable_binginstantindex) && $enable_binginstantindex == 'true' ? 'checked' : '',
518 653 $has_api_key ? 'data-auto-enabled="true"' : ''
519 654 );
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>');
655 + echo '<span class="description"><strong>Enable Bing Instant Indexing:</strong> Automatically enabled when you configure an API key below. When enabled, this activates the IndexNow protocol to instantly notify Bing, Yandex, and other search engines about URL changes.</span>';
521 656 },
522 657 $page_slug . '_seo-controls',
523 658 'seo_controls_bing_instant_index'
524 659 );
@@ -559,9 +694,9 @@
559 694 'otto_pixel_uuid',
560 695 $whitelabel_otto_name . ' Pixel UUID',
561 696 function() use ($option_key) {
562 697 $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]"/>');
698 + echo '<input type="text" size="40" value = "'.esc_attr($value).'" name="' . esc_attr($option_key) . '[general][otto_pixel_uuid]"/>';
564 699 },
565 700 $page_slug . '_general',
566 701 $SECTION_METASYNC
567 702 );
@@ -573,12 +708,12 @@
573 708 'Disable ' . $whitelabel_otto_name . ' for Logged in Users',
574 709 function() use ($whitelabel_otto_name, $option_key) {
575 710 $otto_disable_on_loggedin = Metasync::get_option('general')['otto_disable_on_loggedin'] ?? '';
576 711 printf(
577 - '<input type="checkbox" id="otto_disable_on_loggedin" name="' . $option_key . '[general][otto_disable_on_loggedin]" value="true" %s />',
712 + '<input type="checkbox" id="otto_disable_on_loggedin" name="' . esc_attr($option_key) . '[general][otto_disable_on_loggedin]" value="true" %s />',
578 713 isset($otto_disable_on_loggedin) && $otto_disable_on_loggedin == 'true' ? 'checked' : ''
579 714 );
580 - printf('<span class="description"> This disables '.$whitelabel_otto_name.' when logged in to allow editing original page contents</span>');
715 + echo '<span class="description"> This disables '.esc_html($whitelabel_otto_name).' when logged in to allow editing original page contents</span>';
581 716 },
582 717 $page_slug . '_general',
583 718 $SECTION_METASYNC
584 719 );
@@ -589,9 +724,9 @@
589 724 'Disable ' . $whitelabel_otto_name . ' Frontend Toolbar',
590 725 function() use ($whitelabel_otto_name, $option_key) {
591 726 $otto_disable_toolbar = Metasync::get_option('general')['otto_disable_preview_button'] ?? false;
592 727 printf(
593 - '<input type="checkbox" id="otto_disable_preview_button" name="' . $option_key . '[general][otto_disable_preview_button]" value="true" %s />',
728 + '<input type="checkbox" id="otto_disable_preview_button" name="' . esc_attr($option_key) . '[general][otto_disable_preview_button]" value="true" %s />',
594 729 filter_var($otto_disable_toolbar, FILTER_VALIDATE_BOOLEAN) ? 'checked' : ''
595 730 );
596 731 printf('<span class="description"> Hide the entire frontend toolbar (status indicator, preview button, and debug button) on the frontend. %s functionality will still work, but the toolbar controls will be hidden.</span>', esc_html($whitelabel_otto_name));
597 732 },
@@ -598,8 +733,43 @@
598 733 $page_slug . '_general',
599 734 $SECTION_METASYNC
600 735 );
601 736
737 + # Global SEO title & description priority.
738 + #
739 + # Unchecked (the default) is the behaviour every site already runs: a
740 + # value the customer typed in the sidebar outranks OTTO's suggestion.
741 + # Checked hands precedence to OTTO, which is what makes a successful
742 + # OTTO deployment visible on a page that also holds a custom value.
743 + #
744 + # This only re-orders what RENDERS. Nothing is written to or deleted
745 + # from the stored post meta, so unchecking it restores the custom value
746 + # untouched. Applies to the SEO title and meta description only.
747 + add_settings_field(
748 + 'seo_priority',
749 + 'SEO Title & Description Priority',
750 + function() use ($whitelabel_otto_name, $option_key) {
751 + $seo_priority = Metasync::get_option('general')['seo_priority'] ?? '';
752 + $checked = $seo_priority === Metasync_Seo_Precedence::PRIORITY_OTTO ? 'checked' : '';
753 + $name = $option_key . '[general][seo_priority]';
754 +
755 + echo '<div class="metasync-seo-priority-control">';
756 + printf(
757 + '<label for="seo_priority"><input type="checkbox" id="seo_priority" name="%s" value="otto" %s /> <strong>Prioritize approved %s values</strong></label>',
758 + esc_attr($name),
759 + esc_attr($checked),
760 + esc_html($whitelabel_otto_name)
761 + );
762 + printf(
763 + '<p class="description" id="seo_priority_description"><strong>Off:</strong> Your custom title and description appear first; %1$s fills in only where you have no value.<br><strong>On:</strong> Approved %1$s values appear first; your custom values remain the fallback.<br><span class="metasync-seo-priority-note">Your saved values are never deleted or overwritten.</span></p>',
764 + esc_html($whitelabel_otto_name)
765 + );
766 + echo '</div>';
767 + },
768 + $page_slug . '_general',
769 + $SECTION_METASYNC
770 + );
771 +
602 772 # WP Rocket compatibility mode setting
603 773 add_settings_field(
604 774 'otto_wp_rocket_compat',
605 775 'WP Rocket Compatibility',
@@ -606,9 +776,9 @@
606 776 function() use ($whitelabel_otto_name, $option_key) {
607 777 $value = Metasync::get_option('general')['otto_wp_rocket_compat'] ?? 'auto';
608 778 $wp_rocket_active = class_exists('WP_Rocket');
609 779
610 - echo '<select id="otto_wp_rocket_compat" name="' . $option_key . '[general][otto_wp_rocket_compat]">';
780 + echo '<select id="otto_wp_rocket_compat" name="' . esc_attr($option_key) . '[general][otto_wp_rocket_compat]">';
611 781 echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
612 782 echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>';
613 783 echo '<option value="http"' . selected($value, 'http', false) . '>HTTP Mode (Safer)</option>';
614 784 echo '<option value="disable_otto"' . selected($value, 'disable_otto', false) . '>Disable ' . esc_html($whitelabel_otto_name) . ' when WP Rocket is active</option>';
@@ -628,8 +798,66 @@
628 798 $page_slug . '_general',
629 799 $SECTION_METASYNC
630 800 );
631 801
802 + # SiteGround Optimizer compatibility mode setting
803 + add_settings_field(
804 + 'otto_sg_optimizer_compat',
805 + 'SiteGround Optimizer Compatibility',
806 + function() use ($option_key) {
807 + $value = Metasync::get_option('general')['otto_sg_optimizer_compat'] ?? 'auto';
808 + $sg_optimizer_active = defined('SiteGround_Optimizer\VERSION') || class_exists('SiteGround_Optimizer\Parser\Parser');
809 +
810 + echo '<select id="otto_sg_optimizer_compat" name="' . esc_attr($option_key) . '[general][otto_sg_optimizer_compat]">';
811 + echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
812 + echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>';
813 + echo '<option value="http"' . selected($value, 'http', false) . '>HTTP Mode (Safer)</option>';
814 + echo '</select>';
815 +
816 + if ($sg_optimizer_active) {
817 + echo '<p class="description" style="color: #0073aa; margin-top: 8px;">✓ SiteGround Optimizer detected - compatibility mode active</p>';
818 + }
819 +
820 + echo '<p class="description" style="margin-top: 8px;">';
821 + 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>';
822 + echo '<strong>Buffer Mode (Faster):</strong> Renders in-process with no internal page fetch, roughly halving uncached render time. Best when pages can never be host-cached anyway (e.g. a plugin starts a PHP session on every request — though MetaSync already removes that automatically for BookingPress). May conflict with Divi-style themes.<br>';
823 + echo '<strong>HTTP Mode:</strong> Forces the internal HTTP fetch method. Slower but more compatible.';
824 + echo '</p>';
825 + },
826 + $page_slug . '_general',
827 + $SECTION_METASYNC
828 + );
829 +
830 + # BookingPress front-end session compatibility setting.
831 + # Only registered when BookingPress is actually present on the site:
832 + # the shim no-ops without it, so the field would be noise for every
833 + # other install. The stored value survives while the field is hidden
834 + # (the save loop is isset-guarded and keeps absent keys).
835 + if (class_exists('bookingpress_spam_protection')) {
836 + add_settings_field(
837 + 'bookingpress_session_compat',
838 + 'BookingPress Session Compatibility',
839 + function() use ($option_key) {
840 + $value = Metasync::get_option('general')['bookingpress_session_compat'] ?? 'auto';
841 +
842 + echo '<select id="bookingpress_session_compat" name="' . esc_attr($option_key) . '[general][bookingpress_session_compat]">';
843 + echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
844 + echo '<option value="off"' . selected($value, 'off', false) . '>Disabled</option>';
845 + echo '</select>';
846 +
847 + 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>';
848 +
849 + echo '<p class="description" style="margin-top: 8px;">';
850 + 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>';
851 + echo '<strong>Auto (Recommended):</strong> Removes the unnecessary session start on public pages so page caching can work.<br>';
852 + echo '<strong>Disabled:</strong> Leaves BookingPress exactly as installed (no page caching while it stays active).';
853 + echo '</p>';
854 + },
855 + $page_slug . '_general',
856 + $SECTION_METASYNC
857 + );
858 + }
859 +
632 860 /**
633 861 * BOT DETECTION SETTINGS FOR OTTO
634 862 * @since 1.0.0
635 863 */
@@ -640,9 +868,9 @@
640 868 'Disable ' . $whitelabel_otto_name . ' for Bot Traffic',
641 869 function() use ($whitelabel_otto_name, $option_key) {
642 870 $otto_disable_for_bots = Metasync::get_option('general')['otto_disable_for_bots'] ?? false;
643 871 printf(
644 - '<input type="checkbox" id="otto_disable_for_bots" name="' . $option_key . '[general][otto_disable_for_bots]" value="true" %s />',
872 + '<input type="checkbox" id="otto_disable_for_bots" name="' . esc_attr($option_key) . '[general][otto_disable_for_bots]" value="true" %s />',
645 873 filter_var($otto_disable_for_bots, FILTER_VALIDATE_BOOLEAN) ? 'checked' : ''
646 874 );
647 875 printf('<span class="description"> Skip %s processing for detected bots (search engines, crawlers, SEO tools) to reduce unnecessary API calls. View bot statistics in the Bot Statistics page under the SEO dropdown.</span>', esc_html($whitelabel_otto_name));
648 876 },
@@ -656,9 +884,9 @@
656 884 'Bot Whitelist',
657 885 function() use ($whitelabel_otto_name, $option_key) {
658 886 $otto_bot_whitelist = Metasync::get_option('general')['otto_bot_whitelist'] ?? '';
659 887 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>',
888 + '<textarea id="otto_bot_whitelist" name="' . esc_attr($option_key) . '[general][otto_bot_whitelist]" rows="5" cols="50" style="width: 100%%; max-width: 500px; font-family: monospace;">%s</textarea>',
661 889 esc_textarea($otto_bot_whitelist)
662 890 );
663 891 echo '<p class="description">';
664 892 echo 'Enter bot names or user-agent patterns (one per line) that should <strong>always</strong> be processed by ' . esc_html($whitelabel_otto_name) . ', even when "Disable for Bots" is enabled.<br>';
@@ -677,9 +905,9 @@
677 905 'Bot Blacklist',
678 906 function() use ($whitelabel_otto_name, $option_key) {
679 907 $otto_bot_blacklist = Metasync::get_option('general')['otto_bot_blacklist'] ?? '';
680 908 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>',
909 + '<textarea id="otto_bot_blacklist" name="' . esc_attr($option_key) . '[general][otto_bot_blacklist]" rows="5" cols="50" style="width: 100%%; max-width: 500px; font-family: monospace;">%s</textarea>',
682 910 esc_textarea($otto_bot_blacklist)
683 911 );
684 912 echo '<p class="description">';
685 913 echo 'Enter bot names or user-agent patterns (one per line) that should <strong>always</strong> be blocked from ' . esc_html($whitelabel_otto_name) . ' processing, regardless of other settings.<br>';
@@ -701,9 +929,9 @@
701 929 printf(
702 930 '<a href="%s" class="button button-secondary"><span class="dashicons dashicons-chart-bar" style="margin-top:3px;font-size:15px;width:15px;height:15px;"></span> View Bot Statistics</a>',
703 931 esc_url(admin_url('admin.php?page=' . $page_slug . '-bot-statistics'))
704 932 );
705 - printf('<p class="description">View detailed bot detection statistics, breakdown by bot type, and unique bot entries with hit counts.</p>');
933 + echo '<p class="description">View detailed bot detection statistics, breakdown by bot type, and unique bot entries with hit counts.</p>';
706 934 },
707 935 $page_slug . '_general',
708 936 $SECTION_METASYNC
709 937 );
@@ -714,22 +942,20 @@
714 942 'periodic_clear_ottopage_cache',
715 943 'Clear Page Cache',
716 944 function() use ($option_key) {
717 945 $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>');
946 + echo '<select style = "width : 250px" name="' . esc_attr($option_key) . '[general][periodic_clear_ottopage_cache]" id="heading_style">';
947 + echo '<option value="24" '. selected($periodic_clear_ottopage_cache, '24', false) . '>Clear Daily</option>';
948 + echo '<option value="36" '. selected($periodic_clear_ottopage_cache, '36', false) . '>Clear Every 2 days</option>';
949 + echo '<option value="40" '. selected($periodic_clear_ottopage_cache, '40', false) . '>Clear Weekly</option>';
950 + echo '<option value="0"'.selected($periodic_clear_ottopage_cache, '0', false).'>Clear Monthly (Default)</option>';
951 + echo '</select>';
724 952
725 953 # get last cleared timestamp
726 954 $timestamp = get_option('metasync_refresh_all_caches')['pages'] ?? false;
727 955
728 956 if($timestamp > 0){
729 - printf(
730 - '<p class="descriptionValue">last Cleared : '.date('y-m-d H:i:s', $timestamp).'</p>'
731 - );
957 + echo '<p class="descriptionValue">last Cleared : '.esc_html(gmdate('y-m-d H:i:s', $timestamp)).'</p>';
732 958 }
733 959
734 960 },
735 961 $page_slug . '_otto_cache',
@@ -740,22 +966,20 @@
740 966 'periodic_clear_ottopost_cache',
741 967 'Clear Post Cache',
742 968 function() use ($option_key) {
743 969 $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>');
970 + echo '<select style = "width : 250px" name="' . esc_attr($option_key) . '[general][periodic_clear_ottopost_cache]" id="heading_style">';
971 + echo '<option value="24" '. selected($periodic_clear_ottopost_cache, '24', false) . '>Clear Daily</option>';
972 + echo '<option value="36" '. selected($periodic_clear_ottopost_cache, '36', false) . '>Clear Every 2 days</option>';
973 + echo '<option value="40" '. selected($periodic_clear_ottopost_cache, '40', false) . '>Clear Weekly</option>';
974 + echo '<option value="0"'.selected($periodic_clear_ottopost_cache, '0', false).'>Clear Monthly (Default)</option>';
975 + echo '</select>';
750 976
751 977 # get last cleared timestamp
752 978 $timestamp = get_option('metasync_refresh_all_caches')['posts'] ?? false;
753 979
754 980 if($timestamp > 0){
755 - printf(
756 - '<p class="descriptionValue">last Cleared : '.date('y-m-d H:i:s', $timestamp).'</p>'
757 - );
981 + echo '<p class="descriptionValue">last Cleared : '.esc_html(gmdate('y-m-d H:i:s', $timestamp)).'</p>';
758 982 }
759 983 },
760 984 $page_slug . '_otto_cache',
761 985 $SECTION_METASYNC
@@ -765,22 +989,20 @@
765 989 'periodic_clear_otto_cache',
766 990 'Clear all cache',
767 991 function() use ($option_key) {
768 992 $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>');
993 + echo '<select style = "width : 250px" name="' . esc_attr($option_key) . '[general][periodic_clear_otto_cache]" id="heading_style">';
994 + echo '<option value="24" '. selected($periodic_clear_otto_cache, '24', false) . '>Clear Daily</option>';
995 + echo '<option value="36" '. selected($periodic_clear_otto_cache, '36', false) . '>Clear Every 2 days</option>';
996 + echo '<option value="40" '. selected($periodic_clear_otto_cache, '40', false) . '>Clear Weekly</option>';
997 + echo '<option value="0"'.selected($periodic_clear_otto_cache, '0', false).'>Clear Monthly (Default)</option>';
998 + echo '</select>';
775 999
776 1000 # get last cleared timestamp
777 1001 $timestamp = get_option('metasync_refresh_all_caches')['general'] ?? false;
778 1002
779 1003 if($timestamp > 0){
780 - printf(
781 - '<p class="descriptionValue">last Cleared : '.date('y-m-d H:i:s', $timestamp).'</p>'
782 - );
1004 + echo '<p class="descriptionValue">last Cleared : '.esc_html(gmdate('y-m-d H:i:s', $timestamp)).'</p>';
783 1005 }
784 1006
785 1007 },
786 1008 $page_slug . '_otto_cache',
@@ -795,12 +1017,12 @@
795 1017 'Disable Common Robots Meta Box',
796 1018 function() use ($option_key) {
797 1019 $disabled = Metasync::get_option('general')['disable_common_robots_metabox'] ?? false;
798 1020 printf(
799 - '<input type="checkbox" id="disable_common_robots_metabox" name="' . $option_key . '[general][disable_common_robots_metabox]" value="1" %s />',
1021 + '<input type="checkbox" id="disable_common_robots_metabox" name="' . esc_attr($option_key) . '[general][disable_common_robots_metabox]" value="1" %s />',
800 1022 $disabled ? 'checked' : ''
801 1023 );
802 - printf('<span class="description"> Hide the Common Robots Meta box on post/page edit screens</span>');
1024 + printf('<span class="description"> Hide the Common Robots meta box and stop %1$s emitting index/follow robots directives on the front end. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
803 1025 },
804 1026 $page_slug . '_general',
805 1027 $SECTION_METASYNC
806 1028 );
@@ -810,12 +1032,12 @@
810 1032 'Disable Advance Robots Meta Box',
811 1033 function() use ($option_key) {
812 1034 $disabled = Metasync::get_option('general')['disable_advance_robots_metabox'] ?? false;
813 1035 printf(
814 - '<input type="checkbox" id="disable_advance_robots_metabox" name="' . $option_key . '[general][disable_advance_robots_metabox]" value="1" %s />',
1036 + '<input type="checkbox" id="disable_advance_robots_metabox" name="' . esc_attr($option_key) . '[general][disable_advance_robots_metabox]" value="1" %s />',
815 1037 $disabled ? 'checked' : ''
816 1038 );
817 - printf('<span class="description"> Hide the Advance Robots Meta box on post/page edit screens</span>');
1039 + printf('<span class="description"> Hide the Advance Robots meta box and stop %1$s emitting max-snippet, max-image-preview and max-video-preview directives. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
818 1040 },
819 1041 $page_slug . '_general',
820 1042 $SECTION_METASYNC
821 1043 );
@@ -825,12 +1047,12 @@
825 1047 'Disable Redirection Meta Box',
826 1048 function() use ($option_key) {
827 1049 $disabled = Metasync::get_option('general')['disable_redirection_metabox'] ?? false;
828 1050 printf(
829 - '<input type="checkbox" id="disable_redirection_metabox" name="' . $option_key . '[general][disable_redirection_metabox]" value="1" %s />',
1051 + '<input type="checkbox" id="disable_redirection_metabox" name="' . esc_attr($option_key) . '[general][disable_redirection_metabox]" value="1" %s />',
830 1052 $disabled ? 'checked' : ''
831 1053 );
832 - printf('<span class="description"> Hide the Redirection meta box on post/page edit screens</span>');
1054 + echo '<span class="description"> Hide the Redirection meta box and stop redirects created by it from running. Rules added on the Redirections screen are unaffected. Saved redirects are kept.</span>';
833 1055 },
834 1056 $page_slug . '_general',
835 1057 $SECTION_METASYNC
836 1058 );
@@ -840,12 +1062,12 @@
840 1062 'Disable Canonical Meta Box',
841 1063 function() use ($option_key) {
842 1064 $disabled = Metasync::get_option('general')['disable_canonical_metabox'] ?? false;
843 1065 printf(
844 - '<input type="checkbox" id="disable_canonical_metabox" name="' . $option_key . '[general][disable_canonical_metabox]" value="1" %s />',
1066 + '<input type="checkbox" id="disable_canonical_metabox" name="' . esc_attr($option_key) . '[general][disable_canonical_metabox]" value="1" %s />',
845 1067 $disabled ? 'checked' : ''
846 1068 );
847 - printf('<span class="description"> Hide the Canonical meta box on post/page edit screens</span>');
1069 + printf('<span class="description"> Hide the Canonical meta box and stop %1$s emitting or overriding the canonical URL. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
848 1070 },
849 1071 $page_slug . '_general',
850 1072 $SECTION_METASYNC
851 1073 );
@@ -855,12 +1077,12 @@
855 1077 'Disable Social Media & Open Graph Meta Box',
856 1078 function() use ($option_key) {
857 1079 $disabled = Metasync::get_option('general')['disable_social_opengraph_metabox'] ?? false;
858 1080 printf(
859 - '<input type="checkbox" id="disable_social_opengraph_metabox" name="' . $option_key . '[general][disable_social_opengraph_metabox]" value="1" %s />',
1081 + '<input type="checkbox" id="disable_social_opengraph_metabox" name="' . esc_attr($option_key) . '[general][disable_social_opengraph_metabox]" value="1" %s />',
860 1082 $disabled ? 'checked' : ''
861 1083 );
862 - printf('<span class="description"> Hide the Social Media & Open Graph meta box on post/page edit screens</span>');
1084 + printf('<span class="description"> Hide the Social Media &amp; Open Graph meta box and stop %1$s emitting Open Graph and Twitter tags. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
863 1085 },
864 1086 $page_slug . '_general',
865 1087 $SECTION_METASYNC
866 1088 );
@@ -870,12 +1092,12 @@
870 1092 'Disable Schema Markup Meta Box',
871 1093 function() use ($option_key) {
872 1094 $disabled = Metasync::get_option('general')['disable_schema_markup_metabox'] ?? false;
873 1095 printf(
874 - '<input type="checkbox" id="disable_schema_markup_metabox" name="' . $option_key . '[general][disable_schema_markup_metabox]" value="1" %s />',
1096 + '<input type="checkbox" id="disable_schema_markup_metabox" name="' . esc_attr($option_key) . '[general][disable_schema_markup_metabox]" value="1" %s />',
875 1097 $disabled ? 'checked' : ''
876 1098 );
877 - printf('<span class="description"> Hide the Schema Markup meta box on post/page edit screens</span>');
1099 + printf('<span class="description"> Hide the Schema Markup meta box and stop %1$s emitting any JSON-LD, including site-wide Local SEO markup. While disabled, %1$s does not manage or override this data — another SEO plugin, or none, is free to handle it. Saved values are kept.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')));
878 1100 },
879 1101 $page_slug . '_general',
880 1102 $SECTION_METASYNC
881 1103 );
@@ -880,17 +1102,47 @@
880 1102 $SECTION_METASYNC
881 1103 );
882 1104
883 1105 add_settings_field(
1106 + 'disable_language_alternates_metabox',
1107 + 'Disable Language Alternates Meta Box',
1108 + function() use ($option_key) {
1109 + $disabled = Metasync::get_option('general')['disable_language_alternates_metabox'] ?? false;
1110 + printf(
1111 + '<input type="checkbox" id="disable_language_alternates_metabox" name="' . esc_attr($option_key) . '[general][disable_language_alternates_metabox]" value="1" %s />',
1112 + $disabled ? 'checked' : ''
1113 + );
1114 + printf('<span class="description"> Hide the Language Alternates (hreflang) panel in the block editor and stop %1$s emitting any %2$slink rel=&quot;alternate&quot; hreflang%3$s tags on the front end, including the auto-detected WPML entries. While disabled, %1$s does not manage or override this data — another SEO plugin, or WPML itself, is free to handle it. Saved values are kept and restored if you re-enable this.</span>', esc_html(Metasync::get_effective_plugin_name('MetaSync')), '&lt;', '&gt;');
1115 + },
1116 + $page_slug . '_general',
1117 + $SECTION_METASYNC
1118 + );
1119 +
1120 + add_settings_field(
1121 + 'disable_seo_metabox',
1122 + 'Disable SEO Meta Box',
1123 + function() use ($option_key) {
1124 + $disabled = Metasync::get_option('general')['disable_seo_metabox'] ?? false;
1125 + printf(
1126 + '<input type="checkbox" id="disable_seo_metabox" name="' . esc_attr($option_key) . '[general][disable_seo_metabox]" value="1" %s />',
1127 + $disabled ? 'checked' : ''
1128 + );
1129 + echo '<span class="description"> Hide the SEO Title & Meta Description meta box on post/page edit screens</span>';
1130 + },
1131 + $page_slug . '_general',
1132 + $SECTION_METASYNC
1133 + );
1134 +
1135 + add_settings_field(
884 1136 'open_external_links',
885 1137 'Open External Links in New Tab/Window',
886 1138 function() use ($option_key) {
887 1139 $enabled = Metasync::get_option('general')['open_external_links'] ?? false;
888 1140 printf(
889 - '<input type="checkbox" id="open_external_links" name="' . $option_key . '[general][open_external_links]" value="1" %s />',
1141 + '<input type="checkbox" id="open_external_links" name="' . esc_attr($option_key) . '[general][open_external_links]" value="1" %s />',
890 1142 $enabled ? 'checked' : ''
891 1143 );
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>');
1144 + printf('<span class="description"> Automatically add <code>target="_blank"</code> attribute to external links appearing in your posts, pages, and other post types when rendered by %s. The attribute is applied when the url is displayed.</span>', esc_html(Metasync::get_whitelabel_otto_name()));
893 1145 },
894 1146 $page_slug . '_general',
895 1147 $SECTION_METASYNC
896 1148 );
@@ -903,11 +1155,11 @@
903 1155 $current_permalink_structure = get_option('permalink_structure');
904 1156 $current_rewrite_rules = get_option('rewrite_rules');
905 1157 // Check if the current permalink structure is set to "Plain"
906 1158 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>');
1159 + echo '<span class="description" style="color:#ff0000;opacity:1;">To ensure compatibility, Please Update your Permalink structure to any option other than "plain. For any Inquiries contact support <a href="' . esc_url(get_admin_url() . 'options-permalink.php') . '">Check Setting</a> </span>';
908 1160 } else {
909 - printf('<span class="description" style="color:#008000;opacity:1;">Permalink is Okay </span>');
1161 + echo '<span class="description" style="color:#008000;opacity:1;">Permalink is Okay </span>';
910 1162 }
911 1163 },
912 1164 $page_slug . '_general',
913 1165 $SECTION_METASYNC
@@ -919,9 +1171,9 @@
919 1171 'Hide Dashboard',
920 1172 function() use ($option_key) {
921 1173 $hide_dashboard = Metasync::get_option('general')['hide_dashboard_framework'] ?? '';
922 1174 printf(
923 - '<input type="checkbox" id="hide_dashboard_framework" name="' . $option_key . '[general][hide_dashboard_framework]" value="true" %s />',
1175 + '<input type="checkbox" id="hide_dashboard_framework" name="' . esc_attr($option_key) . '[general][hide_dashboard_framework]" value="true" %s />',
924 1176 isset($hide_dashboard) && $hide_dashboard == 'true' ? 'checked' : ''
925 1177 );
926 1178 printf('<span class="description"> Hide the %s dashboard</span>', esc_html(Metasync_Settings_Registration::instance()->get_effective_menu_title()));
927 1179 },
@@ -937,9 +1189,9 @@
937 1189 'Show ' . $this->get_effective_menu_title() . ' Status in Admin Bar',
938 1190 function() use ($option_key) {
939 1191 $show_admin_bar = Metasync::get_option('general')['show_admin_bar_status'] ?? true;
940 1192 printf(
941 - '<input type="checkbox" id="show_admin_bar_status" name="' . $option_key . '[general][show_admin_bar_status]" value="true" %s />',
1193 + '<input type="checkbox" id="show_admin_bar_status" name="' . esc_attr($option_key) . '[general][show_admin_bar_status]" value="true" %s />',
942 1194 $show_admin_bar ? 'checked' : ''
943 1195 );
944 1196 printf('<span class="description">Show the %s status indicator in the WordPress admin bar.</span>', esc_html(Metasync::get_effective_plugin_name()));
945 1197 },
@@ -946,24 +1198,8 @@
946 1198 $page_slug . '_general',
947 1199 $SECTION_METASYNC
948 1200 );
949 1201
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 1202 # Add User Role Sync Setting
967 1203 add_settings_field(
968 1204 'content_genius_sync_roles',
969 1205 'Content Genius User Roles to Sync',
@@ -1011,9 +1247,17 @@
1011 1247 # Safety check for role data
1012 1248 if (!isset($role_details['name'])) {
1013 1249 continue;
1014 1250 }
1015 -
1251 +
1252 + # Only list roles that can author content (have the edit_posts capability).
1253 + # Keep any role already in the saved selection even if it lacks the cap,
1254 + # so existing configurations are preserved and not silently dropped on re-save.
1255 + $caps = isset($role_details['capabilities']) ? $role_details['capabilities'] : array();
1256 + if (empty($caps['edit_posts']) && !in_array($role_key, $selected_roles)) {
1257 + continue;
1258 + }
1259 +
1016 1260 $is_checked = in_array($role_key, $selected_roles);
1017 1261 $role_name = translate_user_role($role_details['name']);
1018 1262 ?>
1019 1263 <label class="metasync-role-option">
@@ -1053,14 +1297,13 @@
1053 1297 $builders = Metasync_HTML_To_Builder_Converter::get_available_builders();
1054 1298 $auto_detected = Metasync_HTML_To_Builder_Converter::auto_detect_builder();
1055 1299
1056 1300 echo '<div class="metasync-page-builder-setting">';
1057 - echo '<style>#default_page_builder:focus { color: #fff; }</style>';
1058 1301 echo '<select id="default_page_builder" name="' . esc_attr($option_key) . '[general][default_page_builder]" style="min-width: 300px;">';
1059 1302
1060 1303 foreach ($builders as $key => $builder) {
1061 1304 $is_detected = $builder['detected'];
1062 - $label = esc_html($builder['label']);
1305 + $label = $builder['label'];
1063 1306 if ($is_detected && $key !== 'gutenberg') {
1064 1307 $label .= ' — Detected';
1065 1308 } elseif (!$is_detected) {
1066 1309 $label .= ' — Not Detected';
@@ -1072,9 +1315,9 @@
1072 1315 '<option value="%s" %s%s>%s</option>',
1073 1316 esc_attr($key),
1074 1317 selected(empty($current) ? 'gutenberg' : $current, $key, false),
1075 1318 $is_detected ? '' : ' disabled',
1076 - $label
1319 + esc_html($label)
1077 1320 );
1078 1321 }
1079 1322
1080 1323 echo '</select>';
@@ -1123,9 +1366,9 @@
1123 1366 printf(
1124 1367 '<a href="%s" class="button button-secondary"><span class="dashicons dashicons-download" style="margin-top:3px;font-size:15px;width:15px;height:15px;"></span> Import from SEO Plugins</a>',
1125 1368 esc_url(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-import-external'))
1126 1369 );
1127 - printf('<p class="description">Import settings and data from other SEO plugins (Yoast, Rank Math, AIOSEO, etc).</p>');
1370 + echo '<p class="description">Import settings and data from other SEO plugins (Yoast, Rank Math, AIOSEO, etc).</p>';
1128 1371 },
1129 1372 $page_slug . '_general',
1130 1373 $SECTION_METASYNC
1131 1374 );
@@ -1137,24 +1380,24 @@
1137 1380 */
1138 1381 if(is_admin()){
1139 1382 add_settings_field(
1140 1383 'white_label_plugin_name',
1141 - 'Plugin Name',
1384 + 'Plugin Name' . Metasync::get_tooltip_icon_html('wl_plugin_name_tooltip', 'The name shown for this plugin throughout the WordPress admin — menus, page titles, and system messages. Replace it with your own brand name (max 18 characters).'),
1142 1385 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>');
1386 + $value = Metasync::get_option('general')['white_label_plugin_name'] ?? '';
1387 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_name]" value="' . esc_attr($value) . '" maxlength="18" />';
1388 + echo '<p class="description">This name will be used for general plugin branding (WordPress menus, page titles, and system messages). Maximum 18 characters.</p>';
1146 1389 },
1147 1390 $page_slug . '_branding',
1148 1391 $SECTION_METASYNC
1149 1392 );
1150 -
1393 +
1151 1394 add_settings_field(
1152 1395 'whitelabel_otto_name',
1153 - 'OTTO Name',
1396 + 'OTTO Name' . Metasync::get_tooltip_icon_html('wl_otto_name_tooltip', 'The name used wherever OTTO is mentioned in the plugin (e.g. "Enable [Name] Server Side Rendering"). Rename it to match your own product branding.'),
1154 1397 function() use ($option_key) {
1155 1398 $value = Metasync::get_option('general')['whitelabel_otto_name'] ?? '';
1156 - printf('<input type="text" name="' . $option_key . '[general][whitelabel_otto_name]" value="' . esc_attr($value) . '" />');
1399 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][whitelabel_otto_name]" value="' . esc_attr($value) . '" />';
1157 1400 $example_name = !empty($value) ? $value : 'OTTO';
1158 1401 printf('<p class="description">This name will be used for OTTO feature references (e.g., "Enable %s Server Side Rendering").</p>', esc_html($example_name));
1159 1402 },
1160 1403 $page_slug . '_branding',
@@ -1159,16 +1402,16 @@
1159 1402 },
1160 1403 $page_slug . '_branding',
1161 1404 $SECTION_METASYNC
1162 1405 );
1163 -
1406 +
1164 1407 add_settings_field(
1165 1408 'whitelabel_logo_light_url',
1166 - 'Logo (Light Theme)',
1409 + 'Logo (Light Theme)' . Metasync::get_tooltip_icon_html('wl_logo_light_tooltip', 'Your logo shown in the admin dashboard when it\'s set to light mode. Leave blank to use the default logo.'),
1167 1410 function() use ($option_key) {
1168 1411 $whitelabel_settings = Metasync::get_whitelabel_settings();
1169 1412 $value = $whitelabel_settings['logo_light'] ?? '';
1170 - printf('<input type="url" name="' . $option_key . '[whitelabel][logo_light]" value="' . esc_attr($value) . '" size="60" />');
1413 + echo '<input type="url" name="' . esc_attr($option_key) . '[whitelabel][logo_light]" value="' . esc_attr($value) . '" size="60" />';
1171 1414 printf('<p class="description">Displayed when the admin UI is in light mode. Leave blank to use the default %s logo.</p>', esc_html(Metasync::get_effective_plugin_name()));
1172 1415 },
1173 1416 $page_slug . '_branding',
1174 1417 $SECTION_METASYNC
@@ -1175,27 +1418,27 @@
1175 1418 );
1176 1419
1177 1420 add_settings_field(
1178 1421 'whitelabel_logo_dark_url',
1179 - 'Logo (Dark Theme)',
1422 + 'Logo (Dark Theme)' . Metasync::get_tooltip_icon_html('wl_logo_dark_tooltip', 'Your logo shown in the admin dashboard when it\'s set to dark mode. Leave blank to use the default logo.'),
1180 1423 function() use ($option_key) {
1181 1424 $whitelabel_settings = Metasync::get_whitelabel_settings();
1182 1425 $value = $whitelabel_settings['logo_dark'] ?? '';
1183 - printf('<input type="url" name="' . $option_key . '[whitelabel][logo_dark]" value="' . esc_attr($value) . '" size="60" />');
1426 + echo '<input type="url" name="' . esc_attr($option_key) . '[whitelabel][logo_dark]" value="' . esc_attr($value) . '" size="60" />';
1184 1427 printf('<p class="description">Displayed when the admin UI is in dark mode. Leave blank to use the default %s logo.</p>', esc_html(Metasync::get_effective_plugin_name()));
1185 1428 },
1186 1429 $page_slug . '_branding',
1187 1430 $SECTION_METASYNC
1188 1431 );
1189 -
1432 +
1190 1433 add_settings_field(
1191 1434 'whitelabel_domain_url',
1192 - 'Dashboard URL',
1435 + 'Dashboard URL' . Metasync::get_tooltip_icon_html('wl_dashboard_url_tooltip', 'The web address of your own branded dashboard, shown to clients instead of the Search Atlas dashboard link. This is cosmetic only — it doesn\'t change where your data is actually stored.'),
1193 1436 function() use ($option_key) {
1194 1437 $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>');
1438 + $value = $whitelabel_settings['domain'] ?? '';
1439 + echo '<input type="url" name="' . esc_attr($option_key) . '[whitelabel][domain]" value="' . esc_attr($value) . '" size="60" />';
1440 + echo '<p class="description">Enter your whitelabel dashboard URL (e.g., https://yourdashboard.com). Used for branding purposes.</p>';
1198 1441 },
1199 1442 $page_slug . '_branding',
1200 1443 $SECTION_METASYNC
1201 1444 );
@@ -1203,9 +1446,9 @@
1203 1446 'white_label_plugin_description',
1204 1447 'Plugin Description',
1205 1448 function() use ($option_key) {
1206 1449 $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) . '" />');
1450 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_description]" value="' . esc_attr($value) . '" />';
1208 1451 },
1209 1452 $page_slug . '_branding',
1210 1453 $SECTION_METASYNC
1211 1454 );
@@ -1214,9 +1457,9 @@
1214 1457 'white_label_plugin_author',
1215 1458 'Author',
1216 1459 function() use ($option_key) {
1217 1460 $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) . '" />');
1461 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_author]" value="' . esc_attr($value) . '" />';
1219 1462 },
1220 1463 $page_slug . '_branding',
1221 1464 $SECTION_METASYNC
1222 1465 );
@@ -1225,9 +1468,9 @@
1225 1468 'white_label_plugin_author_uri',
1226 1469 'Author URI',
1227 1470 function() use ($option_key) {
1228 1471 $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) . '" />');
1472 + # printf('<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_author_uri]" value="' . esc_attr($value) . '" />');
1230 1473 # Fixed printf usage
1231 1474 printf('<input type="text" name="%s" value="%s" />', esc_attr($option_key . '[general][white_label_plugin_author_uri]'), esc_attr($value) );
1232 1475
1233 1476 },
@@ -1238,9 +1481,9 @@
1238 1481 'white_label_plugin_uri',
1239 1482 'Plugin URI',
1240 1483 function() use ($option_key) {
1241 1484 $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) . '" />');
1485 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_uri]" value="' . esc_attr($value) . '" />';
1243 1486 },
1244 1487 $page_slug . '_branding',
1245 1488 $SECTION_METASYNC
1246 1489 );
@@ -1253,10 +1496,11 @@
1253 1496 add_settings_field(
1254 1497 'white_label_plugin_menu_slug',
1255 1498 'Menu Slug',
1256 1499 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) . '" />');
1500 + $value = Metasync::get_option('general')['white_label_plugin_menu_slug'] ?? '';
1501 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_menu_slug]" value="' . esc_attr($value) . '" placeholder="hip-advanced-seo" />';
1502 + echo '<p class="description">Short identifier for the plugin\'s admin URL &mdash; lowercase letters, numbers, and dashes only (e.g. <code>hip-advanced-seo</code>). Don\'t paste a full web address.</p>';
1259 1503 },
1260 1504 $page_slug . '_branding',
1261 1505 $SECTION_METASYNC
1262 1506 );
@@ -1264,9 +1508,9 @@
1264 1508 'white_label_plugin_menu_icon',
1265 1509 'Menu Icon',
1266 1510 function() use ($option_key) {
1267 1511 $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) . '" />');
1512 + echo '<input type="text" name="' . esc_attr($option_key) . '[general][white_label_plugin_menu_icon]" value="' . esc_attr($value) . '" />';
1269 1513 },
1270 1514 $page_slug . '_branding',
1271 1515 $SECTION_METASYNC
1272 1516 );
@@ -1281,22 +1525,22 @@
1281 1525
1282 1526 // Output radio button for Default Style.css active
1283 1527
1284 1528 printf(
1285 - '<input type="radio" id="enable_default" name="' . $option_key . '[general][enabled_plugin_css]" value="default" %s />',
1529 + '<input type="radio" id="enable_default" name="' . esc_attr($option_key) . '[general][enabled_plugin_css]" value="default" %s />',
1286 1530 ($enabled_plugin_css == 'default'||$enabled_plugin_css =='') ? 'checked' : ''
1287 1531 );
1288 - printf('<label for="enable_default">Default</label><br>');
1532 + echo '<label for="enable_default">Default</label><br>';
1289 1533
1290 1534
1291 1535 // Output radio button for Metasync Style
1292 1536 printf(
1293 - '<input type="radio" id="enable_metasync" name="' . $option_key . '[general][enabled_plugin_css]" value="metasync" %s />',
1537 + '<input type="radio" id="enable_metasync" name="' . esc_attr($option_key) . '[general][enabled_plugin_css]" value="metasync" %s />',
1294 1538 ($enabled_plugin_css == 'metasync') ? 'checked' : ''
1295 1539 );
1296 - printf('<label for="enable_metasync">Metasync Style</label>');
1540 + echo '<label for="enable_metasync">Metasync Style</label>';
1297 1541
1298 - printf('<p class="description"> Choose the default page Style Sheet: Default or MetaSync.</p>');
1542 + echo '<p class="description"> Choose the default page Style Sheet: Default or MetaSync.</p>';
1299 1543 },
1300 1544 $page_slug . '_branding',
1301 1545 $SECTION_METASYNC
1302 1546 );
@@ -1306,12 +1550,12 @@
1306 1550 'enabled_elementor_plugin_css',
1307 1551 'Choose Elementor Font Style',
1308 1552 function() use ($option_key) {
1309 1553 $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>');
1554 + echo '<select name="' . esc_attr($option_key) . '[general][enabled_elementor_plugin_css]" id="heading_style">';
1555 + echo '<option value="default"'.selected($enabled_elementor_plugin_css, 'default', false).'>Default</option>';
1556 + echo '<option value="custom" '. selected($enabled_elementor_plugin_css, 'custom', false) . '>Custom</option>';
1557 + echo '</select>';
1314 1558 },
1315 1559 $page_slug . '_branding',
1316 1560 $SECTION_METASYNC
1317 1561 );
@@ -1319,9 +1563,9 @@
1319 1563 'enabled_elementor_plugin_css_color',
1320 1564 'Choose Elementor Font Color',
1321 1565 function() use ($option_key) {
1322 1566 $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.'">');
1567 + echo '<input type="color" id="elementor_default_color_metasync" name="' . esc_attr($option_key) . '[general][enabled_elementor_plugin_css_color]" value="'.esc_attr($enabled_elementor_plugin_css_color).'">';
1324 1568 },
1325 1569 $page_slug . '_branding',
1326 1570 $SECTION_METASYNC
1327 1571 );
@@ -1327,14 +1571,15 @@
1327 1571 );
1328 1572
1329 1573 add_settings_field(
1330 1574 'whitelabel_settings_password',
1331 - 'Settings Password',
1575 + '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 1576 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>');
1577 + // Decrypted for display — this field only renders after the
1578 + // whitelabel password gate has been passed.
1579 + $value = Metasync::get_whitelabel_password();
1580 + echo '<input type="password" name="' . esc_attr($option_key) . '[whitelabel][settings_password]" value="' . esc_attr($value) . '" size="30" autocomplete="new-password" />';
1581 + echo '<p class="description">Set a custom password to protect the branding settings section.</p>';
1337 1582 },
1338 1583 $page_slug . '_branding',
1339 1584 $SECTION_METASYNC
1340 1585 );
@@ -1340,15 +1585,15 @@
1340 1585 );
1341 1586
1342 1587 add_settings_field(
1343 1588 'whitelabel_recovery_email',
1344 - 'Recovery Email',
1589 + '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 1590 function() use ($option_key) {
1346 1591 $whitelabel_settings = Metasync::get_whitelabel_settings();
1347 1592 $value = $whitelabel_settings['recovery_email'] ?? '';
1348 1593 $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>');
1594 + printf('<input type="email" name="' . esc_attr($option_key) . '[whitelabel][recovery_email]" value="' . esc_attr($value) . '" size="30" autocomplete="email" %s />', $has_password ? 'required' : '');
1595 + echo '<p class="description">Email address to receive password recovery. <strong>Required when password is set.</strong></p>';
1351 1596 },
1352 1597 $page_slug . '_branding',
1353 1598 $SECTION_METASYNC
1354 1599 );
@@ -1365,9 +1610,10 @@
1365 1610 while (count($links) < 5) {
1366 1611 $links[] = ['label' => '', 'url' => '', 'external' => false];
1367 1612 }
1368 1613 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;">';
1614 + 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>';
1615 + echo '<table class="widefat metasync-quick-links-table" style="max-width:560px;">';
1370 1616 echo '<thead><tr><th>Label</th><th>URL</th><th>Open in new tab</th></tr></thead><tbody>';
1371 1617 foreach (array_slice($links, 0, 5) as $i => $link) {
1372 1618 $label = esc_attr($link['label'] ?? '');
1373 1619 $url = esc_attr($link['url'] ?? '');
@@ -1377,11 +1623,11 @@
1377 1623 <td><input type="text" name="%s[whitelabel][quick_links][%d][label]" value="%s" placeholder="Link label" style="width:100%%" /></td>
1378 1624 <td><input type="url" name="%s[whitelabel][quick_links][%d][url]" value="%s" placeholder="https://…" style="width:100%%" /></td>
1379 1625 <td style="text-align:center"><input type="checkbox" name="%s[whitelabel][quick_links][%d][external]" value="1" %s /></td>
1380 1626 </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)
1627 + esc_attr($option_key), esc_attr($i), esc_attr($label),
1628 + esc_attr($option_key), esc_attr($i), esc_attr($url),
1629 + esc_attr($option_key), esc_attr($i), checked($external, true, false)
1384 1630 );
1385 1631 }
1386 1632 echo '</tbody></table>';
1387 1633 },
@@ -1408,9 +1654,9 @@
1408 1654 'Organization Name',
1409 1655 function() use ($option_key) {
1410 1656 $schema = Metasync::get_option('schema') ?? [];
1411 1657 $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));
1658 + printf('<input type="text" name="' . esc_attr($option_key) . '[schema][org_name]" value="%s" size="40" placeholder="Your organization name" />', esc_attr($value));
1413 1659 },
1414 1660 $schema_page,
1415 1661 'metasync_schema_org'
1416 1662 );
@@ -1420,9 +1666,9 @@
1420 1666 'Organization URL',
1421 1667 function() use ($option_key) {
1422 1668 $schema = Metasync::get_option('schema') ?? [];
1423 1669 $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));
1670 + printf('<input type="url" name="' . esc_attr($option_key) . '[schema][org_url]" value="%s" size="40" placeholder="https://example.com" />', esc_attr($value));
1425 1671 },
1426 1672 $schema_page,
1427 1673 'metasync_schema_org'
1428 1674 );
@@ -1428,13 +1674,13 @@
1428 1674 );
1429 1675
1430 1676 add_settings_field(
1431 1677 'schema_org_logo',
1432 - 'Organization Logo URL',
1678 + 'Organization Logo URL' . Metasync::get_tooltip_icon_html('schema_org_logo_tooltip', 'A direct link to your logo image. Google uses this in your Organization knowledge panel — use a clear, square-ish image on a plain background, ideally at least 112x112 pixels.'),
1433 1679 function() use ($option_key) {
1434 1680 $schema = Metasync::get_option('schema') ?? [];
1435 1681 $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));
1682 + printf('<input type="url" name="' . esc_attr($option_key) . '[schema][org_logo]" value="%s" size="40" placeholder="https://example.com/logo.png" />', esc_attr($value));
1437 1683 },
1438 1684 $schema_page,
1439 1685 'metasync_schema_org'
1440 1686 );
@@ -1444,9 +1690,9 @@
1444 1690 'Contact Telephone',
1445 1691 function() use ($option_key) {
1446 1692 $schema = Metasync::get_option('schema') ?? [];
1447 1693 $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));
1694 + printf('<input type="text" name="' . esc_attr($option_key) . '[schema][org_contact_telephone]" value="%s" size="30" placeholder="+1-555-1234" />', esc_attr($value));
1449 1695 },
1450 1696 $schema_page,
1451 1697 'metasync_schema_org'
1452 1698 );
@@ -1452,13 +1698,13 @@
1452 1698 );
1453 1699
1454 1700 add_settings_field(
1455 1701 'schema_org_contact_type',
1456 - 'Contact Type',
1702 + 'Contact Type' . Metasync::get_tooltip_icon_html('schema_org_contact_type_tooltip', 'What this phone number is for, using schema.org wording such as "customer support", "sales", or "technical support". It tells search engines how the number should be labeled.'),
1457 1703 function() use ($option_key) {
1458 1704 $schema = Metasync::get_option('schema') ?? [];
1459 1705 $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));
1706 + printf('<input type="text" name="' . esc_attr($option_key) . '[schema][org_contact_type]" value="%s" size="30" placeholder="customer support" />', esc_attr($value));
1461 1707 },
1462 1708 $schema_page,
1463 1709 'metasync_schema_org'
1464 1710 );
@@ -1464,13 +1710,13 @@
1464 1710 );
1465 1711
1466 1712 add_settings_field(
1467 1713 'schema_org_same_as',
1468 - 'Social Profile URLs',
1714 + 'Social Profile URLs' . Metasync::get_tooltip_icon_html('schema_org_same_as_tooltip', 'Links to your official profiles on other sites (Facebook, X/Twitter, LinkedIn, Wikipedia, etc.), one per line. This is the schema.org "sameAs" property — it helps search engines confirm these accounts are really you.'),
1469 1715 function() use ($option_key) {
1470 1716 $schema = Metasync::get_option('schema') ?? [];
1471 1717 $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));
1718 + printf('<textarea name="' . esc_attr($option_key) . '[schema][org_same_as]" rows="4" cols="50" placeholder="One URL per line&#10;https://facebook.com/...&#10;https://twitter.com/...">%s</textarea>', esc_textarea($value));
1473 1719 echo '<p class="description">Enter one social profile URL per line.</p>';
1474 1720 },
1475 1721 $schema_page,
1476 1722 'metasync_schema_org'
@@ -1490,9 +1736,9 @@
1490 1736 'WebSite Name',
1491 1737 function() use ($option_key) {
1492 1738 $schema = Metasync::get_option('schema') ?? [];
1493 1739 $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));
1740 + printf('<input type="text" name="' . esc_attr($option_key) . '[schema][website_name]" value="%s" size="40" placeholder="Your website name" />', esc_attr($value));
1495 1741 },
1496 1742 $schema_page,
1497 1743 'metasync_schema_website'
1498 1744 );
@@ -1502,9 +1748,9 @@
1502 1748 'WebSite URL',
1503 1749 function() use ($option_key) {
1504 1750 $schema = Metasync::get_option('schema') ?? [];
1505 1751 $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));
1752 + printf('<input type="url" name="' . esc_attr($option_key) . '[schema][website_url]" value="%s" size="40" placeholder="https://example.com" />', esc_attr($value));
1507 1753 },
1508 1754 $schema_page,
1509 1755 'metasync_schema_website'
1510 1756 );
@@ -1510,14 +1756,14 @@
1510 1756 );
1511 1757
1512 1758 add_settings_field(
1513 1759 'schema_website_searchbox',
1514 - 'Enable Sitelinks Searchbox',
1760 + '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 1761 function() use ($option_key) {
1516 1762 $schema = Metasync::get_option('schema') ?? [];
1517 1763 $value = $schema['website_searchbox'] ?? false;
1518 1764 printf(
1519 - '<input type="checkbox" name="' . $option_key . '[schema][website_searchbox]" value="1" %s />',
1765 + '<input type="checkbox" name="' . esc_attr($option_key) . '[schema][website_searchbox]" value="1" %s />',
1520 1766 checked($value, true, false)
1521 1767 );
1522 1768 echo '<span class="description">Adds SearchAction to WebSite schema for Google Sitelinks Searchbox.</span>';
1523 1769 },
@@ -1535,14 +1781,14 @@
1535 1781 );
1536 1782
1537 1783 add_settings_field(
1538 1784 'schema_override_woocommerce',
1539 - 'Override WooCommerce Product Schema',
1785 + '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 1786 function() use ($option_key) {
1541 1787 $schema = Metasync::get_option('schema') ?? [];
1542 1788 $value = $schema['override_woocommerce_schema'] ?? false;
1543 1789 printf(
1544 - '<input type="checkbox" name="' . $option_key . '[schema][override_woocommerce_schema]" value="1" %s />',
1790 + '<input type="checkbox" name="' . esc_attr($option_key) . '[schema][override_woocommerce_schema]" value="1" %s />',
1545 1791 checked($value, true, false)
1546 1792 );
1547 1793 echo '<span class="description">When unchecked (default), MetaSync suppresses its Product schema when WooCommerce is active to avoid duplication. Check to override WooCommerce\'s native Product schema.</span>';
1548 1794 },
@@ -1561,9 +1807,9 @@
1561 1807 );
1562 1808
1563 1809 add_settings_field(
1564 1810 'google_site_verification',
1565 - 'Google Search Console',
1811 + '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 1812 array( Metasync_Settings_Fields::instance(), 'google_site_verification_callback' ),
1567 1813 $page_slug . '_searchengines-verification',
1568 1814 $SECTION_SEARCHENGINE
1569 1815 );
@@ -1569,9 +1815,9 @@
1569 1815 );
1570 1816
1571 1817 add_settings_field(
1572 1818 'bing_site_verification',
1573 - 'Bing Webmaster Tools',
1819 + '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 1820 array( Metasync_Settings_Fields::instance(), 'bing_site_verification_callback' ),
1575 1821 $page_slug . '_searchengines-verification',
1576 1822 $SECTION_SEARCHENGINE
1577 1823 );
@@ -1577,9 +1823,9 @@
1577 1823 );
1578 1824
1579 1825 add_settings_field(
1580 1826 'yandex_site_verification',
1581 - 'Yandex Webmaster',
1827 + '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 1828 array( Metasync_Settings_Fields::instance(), 'yandex_site_verification_callback' ),
1583 1829 $page_slug . '_searchengines-verification',
1584 1830 $SECTION_SEARCHENGINE
1585 1831 );
@@ -1585,9 +1831,9 @@
1585 1831 );
1586 1832
1587 1833 add_settings_field(
1588 1834 'pinterest_site_verification',
1589 - 'Pinterest',
1835 + '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 1836 array( Metasync_Settings_Fields::instance(), 'pinterest_site_verification_callback' ),
1591 1837 $page_slug . '_searchengines-verification',
1592 1838 $SECTION_SEARCHENGINE
1593 1839 );
@@ -1593,12 +1839,12 @@
1593 1839 );
1594 1840
1595 1841 add_settings_field(
1596 1842 'baidu_site_verification',
1597 - 'Baidu',
1843 + 'Baidu' . Metasync::get_tooltip_icon_html('baidu_site_verification_tooltip', 'Paste the verification code Baidu Webmaster gave you. This lets Baidu confirm you own this site — it does not change anything visitors see.'),
1598 1844 function() use ($option_key) {
1599 1845 $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 ) );
1846 + printf( '<input type="text" id="baidu_site_verification" name="' . esc_attr($option_key) . '[searchengines][baidu_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1601 1847 },
1602 1848 $page_slug . '_searchengines-verification',
1603 1849 $SECTION_SEARCHENGINE
1604 1850 );
@@ -1604,12 +1850,12 @@
1604 1850 );
1605 1851
1606 1852 add_settings_field(
1607 1853 'alexa_site_verification',
1608 - 'Alexa',
1854 + 'Alexa' . Metasync::get_tooltip_icon_html('alexa_site_verification_tooltip', 'Paste the verification code your Alexa/site directory gave you. This confirms you own this site — it does not change anything visitors see.'),
1609 1855 function() use ($option_key) {
1610 1856 $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 ) );
1857 + printf( '<input type="text" id="alexa_site_verification" name="' . esc_attr($option_key) . '[searchengines][alexa_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1612 1858 },
1613 1859 $page_slug . '_searchengines-verification',
1614 1860 $SECTION_SEARCHENGINE
1615 1861 );
@@ -1615,12 +1861,12 @@
1615 1861 );
1616 1862
1617 1863 add_settings_field(
1618 1864 'norton_save_site_verification',
1619 - 'Norton Safe Web',
1865 + 'Norton Safe Web' . Metasync::get_tooltip_icon_html('norton_site_verification_tooltip', 'Paste the verification code Norton Safe Web gave you. This confirms you own this site — it does not change anything visitors see.'),
1620 1866 function() use ($option_key) {
1621 1867 $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 ) );
1868 + printf( '<input type="text" id="norton_save_site_verification" name="' . esc_attr($option_key) . '[searchengines][norton_save_site_verification]" value="%s" size="50" />', esc_attr( $value ) );
1623 1869 },
1624 1870 $page_slug . '_searchengines-verification',
1625 1871 $SECTION_SEARCHENGINE
1626 1872 );
@@ -1662,9 +1908,9 @@
1662 1908 );
1663 1909
1664 1910 $fields_instance = Metasync_Settings_Fields::instance();
1665 1911
1666 - add_settings_field( 'local_seo_person_organization', 'Type', array( $fields_instance, 'local_seo_person_organization_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1912 + 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 1913 add_settings_field( 'local_seo_name', 'Name', array( $fields_instance, 'local_seo_name_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1668 1914 add_settings_field( 'local_seo_logo', 'Logo', array( $fields_instance, 'local_seo_logo_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1669 1915 add_settings_field( 'local_seo_url', 'Website URL', array( $fields_instance, 'local_seo_url_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1670 1916 add_settings_field( 'local_seo_email', 'Email', array( $fields_instance, 'local_seo_email_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
@@ -1669,131 +1915,24 @@
1669 1915 add_settings_field( 'local_seo_url', 'Website URL', array( $fields_instance, 'local_seo_url_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1670 1916 add_settings_field( 'local_seo_email', 'Email', array( $fields_instance, 'local_seo_email_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1671 1917 add_settings_field( 'local_seo_phone', 'Phone', array( $fields_instance, 'local_seo_phone_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1672 1918 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 );
1919 + 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 );
1920 + 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 1921 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 );
1922 + 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 1923 add_settings_field( 'local_seo_about_page', 'About Page', array( $fields_instance, 'local_seo_about_page_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1678 1924 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 );
1925 + add_settings_field( 'local_seo_map_key', 'Google Maps Key' . Metasync::get_tooltip_icon_html('local_seo_map_key_tooltip', 'A Google Maps API key from your Google Cloud account. Only needed if you want an embedded map; it lets Google bill and authorize the map requests.'), array( $fields_instance, 'local_seo_map_key_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1926 + add_settings_field( 'local_seo_geo_coordinates', 'Geo Coordinates' . Metasync::get_tooltip_icon_html('local_seo_geo_coordinates_tooltip', 'The exact latitude and longitude of your business. This helps search engines and maps pin your location precisely, especially when your street address is ambiguous.'), array( $fields_instance, 'local_seo_geo_coordinates_callback' ), $page_slug . '_local-seo', $SECTION_LOCALSEO );
1681 1927
1682 1928 // ----------------------------------------------------------------
1683 - // Breadcrumbs page
1929 + // Breadcrumbs page — intentionally registers no Settings API fields.
1930 + // The dedicated Breadcrumbs admin page renders the richer controls
1931 + // directly via Metasync_Settings_Fields::render_breadcrumbs_section()
1932 + // and writes the same metasync_options[breadcrumbs] keys.
1684 1933 // ----------------------------------------------------------------
1685 - add_settings_section(
1686 - $SECTION_BREADCRUMBS,
1687 - '',
1688 - function() {},
1689 - $page_slug . '_breadcrumbs'
1690 - );
1691 1934
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 1935 # Open Graph / Article meta toggles — surface each new tag family as its
1797 1936 # own opt-out checkbox on the main Settings page (Open Graph accordion).
1798 1937 add_settings_field(
1799 1938 'og_image_dimensions',
@@ -1845,8 +1984,97 @@
1845 1984
1846 1985 }
1847 1986
1848 1987 /**
1988 + * Coerce a loosely-typed option value into an array.
1989 + *
1990 + * The value arrives untyped (a crafted or partial submission may send a
1991 + * scalar for an option stored as an array), so the check lives behind an
1992 + * untyped parameter instead of an inline is_array() guard on a value the
1993 + * docblock already narrows.
1994 + *
1995 + * @param mixed $value Raw option value.
1996 + * @return array The value as an array, or an empty array.
1997 + */
1998 + private static function coerce_to_array($value)
1999 + {
2000 + return is_array($value) ? $value : array();
2001 + }
2002 +
2003 + /**
2004 + * Recursively merge sanitized settings over raw and stored layers.
2005 + *
2006 + * Later layers win per-key; associative arrays merge key-by-key so
2007 + * submitted values only replace their own field, while sequential
2008 + * lists (rows of opening hours, phone numbers) replace wholesale
2009 + * so deleted rows stay deleted.
2010 + *
2011 + * @param array $base Stored option layer (lowest precedence).
2012 + * @param array ...$layers Layers to fold in, lowest precedence first
2013 + * (raw input, then sanitized values).
2014 + * @return array The merged option array.
2015 + */
2016 + private static function merge_option_layers(array $base, array ...$layers)
2017 + {
2018 + foreach ($layers as $layer) {
2019 + foreach ($layer as $key => $value) {
2020 + if (is_array($value)
2021 + && isset($base[$key])
2022 + && is_array($base[$key])
2023 + && !self::is_sequential_list($value)
2024 + && !self::clears_a_stored_list($value, $base[$key])
2025 + ) {
2026 + $base[$key] = self::merge_option_layers($base[$key], $value);
2027 + } else {
2028 + $base[$key] = $value;
2029 + }
2030 + }
2031 + }
2032 + return $base;
2033 + }
2034 +
2035 + /**
2036 + * Whether the array is a numerically-indexed list (keys 0..n-1).
2037 + *
2038 + * Answers false for the empty array — range(0, -1) counts downwards and
2039 + * yields [0, -1], not []. That is load-bearing rather than incidental: an
2040 + * empty array is ambiguous, and clears_a_stored_list() is where the
2041 + * ambiguity is resolved.
2042 + *
2043 + * @param array $value Array to inspect.
2044 + * @return bool True for lists, false for associative maps.
2045 + */
2046 + private static function is_sequential_list(array $value)
2047 + {
2048 + return array_keys($value) === range(0, count($value) - 1);
2049 + }
2050 +
2051 + /**
2052 + * Whether an empty submitted value is a stored list being cleared.
2053 + *
2054 + * An empty array reaches the merge meaning one of two opposite things, and
2055 + * only the stored value tells them apart.
2056 + *
2057 + * When what is stored is a list — the roles of a multi-select, the rows of
2058 + * a repeater — an empty submission means the user removed every entry, and
2059 + * it has to replace the stored value or the clear is silently ignored.
2060 + *
2061 + * When what is stored is an associative map, an empty submission means the
2062 + * form carried nothing for that section: a tab whose every checkbox is
2063 + * unchecked posts no fields at all. Replacing there would wipe the section,
2064 + * API key included, which is the whole failure this merge exists to
2065 + * prevent. So that case still merges, leaving the stored keys standing.
2066 + *
2067 + * @param array $value The submitted value.
2068 + * @param array $stored The value already in the option.
2069 + * @return bool True when the submission is clearing a list.
2070 + */
2071 + private static function clears_a_stored_list(array $value, array $stored)
2072 + {
2073 + return $value === array() && self::is_sequential_list($stored);
2074 + }
2075 +
2076 + /**
1849 2077 * Sanitize each setting field as needed.
1850 2078 *
1851 2079 * Formerly Metasync_Admin::sanitize().
1852 2080 *
@@ -1854,21 +2082,62 @@
1854 2082 * @return array Sanitized settings
1855 2083 */
1856 2084 public function sanitize($input)
1857 2085 {
1858 - $new_input = Metasync::get_option();
2086 + $submitted_generation = isset($_POST['metasync_pw_reset_generation']) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2087 + ? absint($_POST['metasync_pw_reset_generation']) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2088 + : null;
2089 + // Legacy/stale forms that include a password but no reset generation
2090 + // must never be allowed to overwrite a newer recovery password.
2091 + if (!self::$allow_recovery_password_write
2092 + && $submitted_generation === null
2093 + && isset($input['whitelabel']['settings_password'])) {
2094 + unset($input['whitelabel']['settings_password']);
2095 + add_settings_error(
2096 + 'metasync_messages',
2097 + 'metasync_password_generation_missing',
2098 + __('This settings form is stale. Reload the page before changing the password.', 'metasync'),
2099 + 'error'
2100 + );
2101 + }
2102 + // Every settings save rewrites the aggregate option, including its
2103 + // current password value. Serialize the full save with recovery even
2104 + // when the submitted tab does not expose the password field.
2105 + if (!$this->acquire_settings_recovery_lock()) {
2106 + add_settings_error(
2107 + 'metasync_messages',
2108 + 'metasync_password_update_busy',
2109 + __('Password recovery is being processed. No settings were saved; please try again.', 'metasync'),
2110 + 'error'
2111 + );
2112 + } else {
2113 + self::$options_generation_at_sanitize = $submitted_generation !== null
2114 + ? $submitted_generation
2115 + : (int) get_option('metasync_pw_reset_generation', 0);
2116 + }
2117 + $input = self::coerce_to_array($input);
2118 +
2119 + // The saved option is assembled from three layers (see the merge at
2120 + // the end of this method): the stored option, the raw submission,
2121 + // and the sanitized values collected in $new_input. Sanitization
2122 + // therefore wins over raw input, a submitted field without a
2123 + // sanitizer still beats its stored value, and stored keys the
2124 + // submission omits survive instead of being dropped.
2125 + $stored_option = self::coerce_to_array(Metasync::get_option());
2126 + $new_input = array();
1859 2127 $whitelabel_validation_failed = false;
2128 + $whitelabel_reset = false;
1860 2129
1861 2130 # Determine which tab is being submitted (same logic as AJAX handler)
1862 2131 # Check POST first (from AJAX), then GET (from form action URL)
1863 2132 $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'])) {
2133 + if (isset($_POST['active_tab'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2134 + $active_tab = sanitize_text_field(wp_unslash($_POST['active_tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
2135 + } elseif (isset($_GET['tab'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- sanitize callback, the caller already verified the nonce
2136 + $active_tab = sanitize_text_field(wp_unslash($_GET['tab'])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- sanitize callback, the caller already verified the nonce
2137 + } elseif (isset($_POST['_wp_http_referer'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
1869 2138 # Parse referer URL to get tab parameter
1870 - $referer = wp_parse_url($_POST['_wp_http_referer']);
2139 + $referer = wp_parse_url(esc_url_raw(wp_unslash($_POST['_wp_http_referer']))); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- sanitize callback, the caller already verified the nonce
1871 2140 if (isset($referer['query'])) {
1872 2141 parse_str($referer['query'], $referer_params);
1873 2142 if (isset($referer_params['tab'])) {
1874 2143 $active_tab = sanitize_text_field($referer_params['tab']);
@@ -1890,9 +2159,15 @@
1890 2159 'disable_redirection_metabox',
1891 2160 'disable_canonical_metabox',
1892 2161 'disable_social_opengraph_metabox',
1893 2162 'disable_schema_markup_metabox',
1894 - 'open_external_links'
2163 + 'disable_language_alternates_metabox',
2164 + 'disable_seo_metabox',
2165 + 'open_external_links',
2166 + // Consent switch for writing into other SEO plugins. Absent from
2167 + // the POST means unchecked, which must resolve to off — a
2168 + // permanent write into another plugin's data is never inferred.
2169 + Metasync_Seo_Backup::CONSENT_OPTION_KEY
1895 2170 ];
1896 2171
1897 2172 foreach ($checkbox_fields as $field) {
1898 2173 if (isset($input['general'][$field])) {
@@ -1897,11 +2172,27 @@
1897 2172 foreach ($checkbox_fields as $field) {
1898 2173 if (isset($input['general'][$field])) {
1899 2174 $new_input['general'][$field] = filter_var($input['general'][$field], FILTER_VALIDATE_BOOLEAN);
1900 2175 } else {
2176 + // Unchecked HTML checkboxes are absent from the submission;
2177 + // persist an explicit false so the stored value cannot leak
2178 + // back through the layered merge.
1901 2179 $new_input['general'][$field] = false;
1902 2180 }
1903 2181 }
2182 +
2183 + // SEO priority is a checkbox whose stored value is a string, so it
2184 + // cannot ride the boolean loop above. Only the exact opt-in value
2185 + // is accepted; unchecked (absent) or anything else resolves to
2186 + // custom-first. Writing an explicit value rather than leaving the
2187 + // key absent matters for the same reason the loop above writes
2188 + // false: the layered merge would otherwise let the stored 'otto'
2189 + // leak back and the box could never be unticked.
2190 + $new_input['general']['seo_priority'] =
2191 + (isset($input['general']['seo_priority'])
2192 + && $input['general']['seo_priority'] === Metasync_Seo_Precedence::PRIORITY_OTTO)
2193 + ? Metasync_Seo_Precedence::PRIORITY_OTTO
2194 + : 'custom';
1904 2195 }
1905 2196
1906 2197 // Site Verification Settings
1907 2198 if (isset($input['searchengines']['bing_site_verification'])) {
@@ -1933,9 +2224,17 @@
1933 2224 if (isset($input['localseo']['local_seo_name'])) {
1934 2225 $new_input['localseo']['local_seo_name'] = sanitize_text_field($input['localseo']['local_seo_name']);
1935 2226 }
1936 2227 if (isset($input['localseo']['local_seo_logo'])) {
1937 - $new_input['localseo']['local_seo_logo'] = sanitize_url($input['localseo']['local_seo_logo']);
2228 + // The logo may be either a media-library attachment ID (what the
2229 + // media picker stores) or a raw URL. sanitize_url() is esc_url_raw(),
2230 + // which prepends a scheme to any value that has none — so an ID like
2231 + // "45589" would be rewritten to "http://45589" and the image lost.
2232 + // Keep the two formats apart so the ID survives a save.
2233 + $logo = trim((string) $input['localseo']['local_seo_logo']);
2234 + $new_input['localseo']['local_seo_logo'] = is_numeric($logo)
2235 + ? absint($logo)
2236 + : sanitize_url($logo);
1938 2237 }
1939 2238 if (isset($input['localseo']['local_seo_url'])) {
1940 2239 $new_input['localseo']['local_seo_url'] = sanitize_url($input['localseo']['local_seo_url']);
1941 2240 }
@@ -1953,10 +2252,10 @@
1953 2252 }
1954 2253 if (isset($input['localseo']['address']['region'])) {
1955 2254 $new_input['localseo']['address']['region'] = sanitize_text_field($input['localseo']['address']['region']);
1956 2255 }
1957 - if (isset($input['localseo']['address']['postancode'])) {
1958 - $new_input['localseo']['address']['postancode'] = sanitize_text_field($input['localseo']['address']['postancode']);
2256 + if (isset($input['localseo']['address']['postalcode'])) {
2257 + $new_input['localseo']['address']['postalcode'] = sanitize_text_field($input['localseo']['address']['postalcode']);
1959 2258 }
1960 2259 if (isset($input['localseo']['address']['country'])) {
1961 2260 $new_input['localseo']['address']['country'] = sanitize_text_field($input['localseo']['address']['country']);
1962 2261 }
@@ -1966,18 +2265,18 @@
1966 2265 if (isset($input['localseo']['local_seo_hours_format'])) {
1967 2266 $new_input['localseo']['local_seo_hours_format'] = sanitize_text_field($input['localseo']['local_seo_hours_format']);
1968 2267 }
1969 2268 if (isset($input['localseo']['days'])) {
1970 - $new_input['localseo']['days'] = sanitize_text_field($input['localseo']['days']);
2269 + $new_input['localseo']['days'] = array_map('sanitize_text_field', (array) $input['localseo']['days']);
1971 2270 }
1972 2271 if (isset($input['localseo']['times'])) {
1973 - $new_input['localseo']['times'] = sanitize_text_field($input['localseo']['times']);
2272 + $new_input['localseo']['times'] = array_map('sanitize_text_field', (array) $input['localseo']['times']);
1974 2273 }
1975 2274 if (isset($input['localseo']['phonetype'])) {
1976 - $new_input['localseo']['phonetype'] = sanitize_text_field($input['localseo']['phonetype']);
2275 + $new_input['localseo']['phonetype'] = array_map('sanitize_text_field', (array) $input['localseo']['phonetype']);
1977 2276 }
1978 2277 if (isset($input['localseo']['phonenumber'])) {
1979 - $new_input['localseo']['phonenumber'] = sanitize_text_field($input['localseo']['phonenumber']);
2278 + $new_input['localseo']['phonenumber'] = array_map('sanitize_text_field', (array) $input['localseo']['phonenumber']);
1980 2279 }
1981 2280 if (isset($input['localseo']['local_seo_price_range'])) {
1982 2281 $new_input['localseo']['local_seo_price_range'] = sanitize_text_field($input['localseo']['local_seo_price_range']);
1983 2282 }
@@ -1995,13 +2294,28 @@
1995 2294 }
1996 2295
1997 2296 // Breadcrumbs Settings
1998 2297 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']);
2298 + // Checkbox fields are only written when the submitting form
2299 + // actually rendered them. render_breadcrumbs_section() pairs every
2300 + // checkbox with a hidden companion input, so the key is always
2301 + // present there and unchecking works. A missing key means the form
2302 + // does not manage that field, so the stored value is preserved
2303 + // instead of being silently reset to false.
2304 + foreach (array('enabled', 'show_current_page', 'disable_schema') as $breadcrumb_flag) {
2305 + if (isset($input['breadcrumbs'][$breadcrumb_flag])) {
2306 + $new_input['breadcrumbs'][$breadcrumb_flag] = !empty($input['breadcrumbs'][$breadcrumb_flag]);
2307 + }
2308 + }
2309 + // The Custom radio always renders and can submit an empty value
2310 + // when selected without typing. An empty separator would render
2311 + // a blank gap between breadcrumb links, so it is dropped and the
2312 + // stored value survives, exactly like an absent key.
2002 2313 if (isset($input['breadcrumbs']['separator'])) {
2003 - $new_input['breadcrumbs']['separator'] = sanitize_text_field($input['breadcrumbs']['separator']);
2314 + $breadcrumb_separator = sanitize_text_field($input['breadcrumbs']['separator']);
2315 + if ('' !== $breadcrumb_separator) {
2316 + $new_input['breadcrumbs']['separator'] = $breadcrumb_separator;
2317 + }
2004 2318 }
2005 2319 if (isset($input['breadcrumbs']['home_label'])) {
2006 2320 $new_input['breadcrumbs']['home_label'] = sanitize_text_field($input['breadcrumbs']['home_label']);
2007 2321 }
@@ -2013,59 +2327,30 @@
2013 2327 }
2014 2328 if (isset($input['breadcrumbs']['archive_label_format'])) {
2015 2329 $new_input['breadcrumbs']['archive_label_format'] = sanitize_text_field($input['breadcrumbs']['archive_label_format']);
2016 2330 }
2331 + // Drop the raw section: the sanitized copies above (including the
2332 + // explicit checkbox booleans) are authoritative for the merge.
2333 + unset($input['breadcrumbs']);
2017 2334 }
2018 2335
2019 - // Code Snippets Settings
2336 + // Code Snippets Settings. These fields intentionally hold raw markup
2337 + // (analytics scripts, verification tags, inline styles) that is echoed
2338 + // verbatim on the front end, so only the request slashes are stripped —
2339 + // tag-stripping sanitizers would destroy the snippet. The form is
2340 + // restricted to users with plugin settings access.
2020 2341 if (isset($input['codesnippets']['header_snippet'])) {
2021 - $new_input['codesnippets']['header_snippet'] = sanitize_text_field($input['codesnippets']['header_snippet']);
2342 + $snippet = wp_unslash($input['codesnippets']['header_snippet']);
2343 + $new_input['codesnippets']['header_snippet'] = is_string($snippet) ? trim($snippet) : '';
2022 2344 }
2023 2345 if (isset($input['codesnippets']['footer_snippet'])) {
2024 - $new_input['codesnippets']['footer_snippet'] = sanitize_text_field($input['codesnippets']['footer_snippet']);
2346 + $snippet = wp_unslash($input['codesnippets']['footer_snippet']);
2347 + $new_input['codesnippets']['footer_snippet'] = is_string($snippet) ? trim($snippet) : '';
2025 2348 }
2026 2349
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 2350 // Common Setting - Global Settings
2063 2351 $common_robots_field = isset($input['common_robots_meta']) ? 'common_robots_meta' : 'common_robots_mata';
2064 2352
2065 - if (isset($input[$common_robots_field]['index'])) {
2066 - $new_input['common_robots_meta']['index'] = boolval($input[$common_robots_field]['index']);
2067 - }
2068 2353 if (isset($input[$common_robots_field]['noindex'])) {
2069 2354 $new_input['common_robots_meta']['noindex'] = boolval($input[$common_robots_field]['noindex']);
2070 2355 }
2071 2356 if (isset($input[$common_robots_field]['nofollow'])) {
@@ -2137,8 +2422,11 @@
2137 2422 ];
2138 2423 foreach ($og_toggle_fields as $field) {
2139 2424 $new_input['common_meta_settings'][$field] = (isset($input['common_meta_settings'][$field]) && $input['common_meta_settings'][$field] === 'true') ? 'true' : 'false';
2140 2425 }
2426 + // Drop the raw section so only the sanitized toggle values reach
2427 + // the stored option.
2428 + unset($input['common_meta_settings']);
2141 2429 }
2142 2430
2143 2431 # Handle whitelabel URL fields with improved empty value handling
2144 2432 if (isset($input['whitelabel'])) {
@@ -2217,10 +2505,31 @@
2217 2505 }
2218 2506 }
2219 2507
2220 2508 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);
2509 + // WordPress unslashes registered settings before invoking the
2510 + // sanitize callback. Preserve the resulting password exactly.
2511 + $password_value = (string) $input['whitelabel']['settings_password'];
2512 + $encrypted_password = Metasync::encrypt_secret($password_value);
2513 + if ($password_value === '') {
2514 + $new_input['whitelabel']['settings_password'] = '';
2515 + } elseif (Metasync::is_encrypted_secret($password_value)) {
2516 + // update_option() may invoke this sanitizer after the
2517 + // AJAX handler has already encrypted the value. Treat
2518 + // valid ciphertext as an idempotent input, not a failed
2519 + // encryption attempt.
2520 + $new_input['whitelabel']['settings_password'] = $password_value;
2521 + } elseif (Metasync::is_encrypted_secret($encrypted_password) && $encrypted_password !== $password_value) {
2522 + $new_input['whitelabel']['settings_password'] = $encrypted_password;
2523 + } else {
2524 + $new_input['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'] ?? '';
2525 + add_settings_error(
2526 + 'metasync_messages',
2527 + 'metasync_password_encryption_failed',
2528 + __('Unable to securely save the password. Please choose a different password.', 'metasync'),
2529 + 'error'
2530 + );
2531 + }
2223 2532 } else {
2224 2533 if (isset($existing_whitelabel['settings_password'])) {
2225 2534 $new_input['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'];
2226 2535 }
@@ -2325,12 +2634,15 @@
2325 2634 $existing_whitelabel = Metasync::get_option()['whitelabel'] ?? [];
2326 2635 $has_existing_whitelabel = !empty($existing_whitelabel['domain']) || !empty($existing_whitelabel['logo']);
2327 2636
2328 2637 if ($has_existing_whitelabel) {
2638 + // A reset replaces the whole section (password ciphertext,
2639 + // palette, quick links and all) rather than merging leaves.
2640 + $whitelabel_reset = true;
2329 2641 $new_input['whitelabel'] = [
2330 2642 'is_whitelabel' => false,
2331 2643 'domain' => '',
2332 - 'logo' => '',
2644 + 'logo' => '',
2333 2645 'company_name' => '',
2334 2646 'updated_at' => time()
2335 2647 ];
2336 2648
@@ -2365,10 +2677,42 @@
2365 2677 do_action('metasync_trigger_immediate_heartbeat', 'Whitelabel domain change - ' . $context);
2366 2678 });
2367 2679 }
2368 2680
2369 - $result = array_merge($new_input, $input);
2681 + // Headless settings are never written through this form. They are owned by
2682 + // Metasync_Headless_Config and saved by their own handler, so anything
2683 + // arriving here under that key is a stale form or a hand-built request.
2684 + // The merge below would let it write over the stored block key by key —
2685 + // switching the mode off that way removes the GraphQL field and breaks
2686 + // every persisted query a headless frontend holds, so the key is dropped
2687 + // rather than merged.
2688 + //
2689 + // Except when the owner is the one writing. register_setting() makes this
2690 + // method a `sanitize_option_metasync_options` filter, and WordPress runs
2691 + // it on every update_option() call for the option, not only on Settings
2692 + // API form posts — so dropping the key unconditionally also dropped
2693 + // Metasync_Headless_Config's own writes, and the Headless Mode block
2694 + // could never save anything on a real request.
2695 + if (!Metasync_Headless_Config::is_writing()) {
2696 + unset($input[Metasync_Headless_Config::SETTINGS_KEY]);
2697 + }
2370 2698
2699 + // Keep the fully sanitized/encrypted white-label values. The layered
2700 + // merge below already lets them win; mirroring them into the raw
2701 + // layer also excludes stray raw whitelabel keys from the saved option.
2702 + if (isset($input['whitelabel'], $new_input['whitelabel'])) {
2703 + $input['whitelabel'] = $new_input['whitelabel'];
2704 + }
2705 +
2706 + // Layered merge: stored option, then the raw submission, then the
2707 + // sanitized values. Replaces the former shallow array_merge(), which
2708 + // let the raw submission overwrite every sanitized section wholesale.
2709 + $result = self::merge_option_layers($stored_option, $input, $new_input);
2710 +
2711 + if ($whitelabel_reset) {
2712 + $result['whitelabel'] = $new_input['whitelabel'];
2713 + }
2714 +
2371 2715 if ($whitelabel_validation_failed) {
2372 2716 $result['whitelabel'] = Metasync::get_option()['whitelabel'] ?? [];
2373 2717 }
2374 2718
@@ -2383,9 +2727,10 @@
2383 2727 * Moved from Metasync_Admin – the admin class now delegates here.
2384 2728 */
2385 2729 public function meta_sync_save_settings()
2386 2730 {
2387 - if (!isset($_POST['meta_sync_nonce']) || !wp_verify_nonce($_POST['meta_sync_nonce'], 'meta_sync_general_setting_nonce')) {
2731 + $meta_sync_nonce = isset($_POST['meta_sync_nonce']) ? sanitize_text_field(wp_unslash($_POST['meta_sync_nonce'])) : '';
2732 + if (!wp_verify_nonce($meta_sync_nonce, 'meta_sync_general_setting_nonce')) {
2388 2733 wp_send_json_error(array('message' => 'Invalid nonce'));
2389 2734 return;
2390 2735 }
2391 2736
@@ -2393,8 +2738,31 @@
2393 2738 wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
2394 2739 return;
2395 2740 }
2396 2741
2742 + self::$defer_settings_recovery_lock_release = true;
2743 + if (!$this->acquire_settings_recovery_lock()) {
2744 + self::$defer_settings_recovery_lock_release = false;
2745 + self::$abort_locked_settings_update = false;
2746 + return wp_send_json_error(array('message' => 'Password recovery is being processed. No settings were saved; please try again.'), 409);
2747 + }
2748 +
2749 + $current_generation = (int) get_option('metasync_pw_reset_generation', 0);
2750 + $password_submitted = isset($_POST['metasync_options']['whitelabel']['settings_password']);
2751 + if ($password_submitted && !isset($_POST['metasync_pw_reset_generation'])) {
2752 + $this->force_release_settings_recovery_lock();
2753 + return wp_send_json_error(array('message' => 'A settings password update requires a current page generation. Reload the page and try again.'), 409);
2754 + }
2755 + if (isset($_POST['metasync_pw_reset_generation'])) {
2756 + self::$options_generation_at_sanitize = absint($_POST['metasync_pw_reset_generation']);
2757 + if ((int) self::$options_generation_at_sanitize !== $current_generation) {
2758 + $this->force_release_settings_recovery_lock();
2759 + return wp_send_json_error(array('message' => 'The settings password changed while this page was open. Reload the page and try again.'), 409);
2760 + }
2761 + } else {
2762 + self::$options_generation_at_sanitize = $current_generation;
2763 + }
2764 +
2397 2765 $text_fields = [
2398 2766 'searchatlas_api_key', 'apikey',
2399 2767 'white_label_plugin_name', 'white_label_plugin_description',
2400 2768 'white_label_plugin_author', 'white_label_plugin_menu_slug',
@@ -2400,9 +2768,11 @@
2400 2768 'white_label_plugin_author', 'white_label_plugin_menu_slug',
2401 2769 'white_label_plugin_menu_icon', 'enabled_plugin_css',
2402 2770 'enabled_elementor_plugin_css_color','enabled_elementor_plugin_css',
2403 2771 'otto_pixel_uuid','periodic_clear_otto_cache','periodic_clear_ottopage_cache',
2404 - 'periodic_clear_ottopost_cache', 'whitelabel_otto_name', 'otto_wp_rocket_compat'
2772 + 'periodic_clear_ottopost_cache', 'whitelabel_otto_name', 'otto_wp_rocket_compat',
2773 + 'otto_sg_optimizer_compat',
2774 + 'bookingpress_session_compat'
2405 2775 ];
2406 2776
2407 2777 $url_fields = [
2408 2778 'white_label_plugin_author_uri', 'white_label_plugin_uri'
@@ -2407,9 +2777,9 @@
2407 2777 $url_fields = [
2408 2778 'white_label_plugin_author_uri', 'white_label_plugin_uri'
2409 2779 ];
2410 2780
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'];
2781 + $bool_fields = ['otto_disable_on_loggedin', 'otto_disable_preview_button', 'otto_disable_for_bots' , 'hide_dashboard_framework', 'show_admin_bar_status','disable_common_robots_metabox', 'disable_advance_robots_metabox', 'disable_redirection_metabox', 'disable_canonical_metabox', 'disable_social_opengraph_metabox', 'disable_schema_markup_metabox', 'disable_language_alternates_metabox', 'disable_seo_metabox', 'open_external_links', Metasync_Seo_Backup::CONSENT_OPTION_KEY];
2412 2782
2413 2783 $url_fields = ['white_label_plugin_author_uri', 'white_label_plugin_uri'];
2414 2784
2415 2785 $metasync_options = Metasync::get_option();
@@ -2421,19 +2791,26 @@
2421 2791 }
2422 2792
2423 2793 $validation_errors = [];
2424 2794
2425 - $active_tab = isset($_POST['active_tab']) ? sanitize_text_field($_POST['active_tab']) : 'general';
2795 + $active_tab = isset($_POST['active_tab']) ? sanitize_text_field(wp_unslash($_POST['active_tab'])) : 'general';
2426 2796 $general_tab_submitted = ($active_tab === 'general');
2427 2797 $whitelabel_tab_submitted = ($active_tab === 'whitelabel');
2428 2798
2799 + // Whitelabel field validation issues are collected per-field and reported back
2800 + // without blocking the rest of the settings save.
2801 + $whitelabel_errors = [];
2802 +
2429 2803 foreach ($text_fields as $field) {
2430 2804
2431 2805 if (isset($_POST['metasync_options']['general'][$field])) {
2432 2806
2433 - $value = trim($_POST['metasync_options']['general'][$field]);
2807 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every storing branch below sanitises via sanitize_text_field/sanitize_title/esc_url_raw before the value reaches the option.
2808 + $value = trim(wp_unslash($_POST['metasync_options']['general'][$field]));
2434 2809
2435 - $whitelabel_clearable_fields = [
2810 + // searchatlas_api_key must be clearable to support key removal
2811 + $clearable_fields = [
2812 + 'searchatlas_api_key',
2436 2813 'white_label_plugin_name',
2437 2814 'white_label_plugin_description',
2438 2815 'white_label_plugin_author',
2439 2816 'white_label_plugin_menu_slug',
@@ -2441,15 +2818,18 @@
2441 2818 'whitelabel_otto_name',
2442 2819 'otto_pixel_uuid'
2443 2820 ];
2444 2821
2445 - if ($value === '' && !in_array($field, $whitelabel_clearable_fields)) {
2822 + if ($value === '' && !in_array($field, $clearable_fields)) {
2446 2823 continue;
2447 2824 }
2448 2825
2449 2826 if ($field === 'white_label_plugin_name') {
2450 - if (strlen($value) > 16) {
2451 - $validation_errors[$field] = 'Plugin name must not exceed 16 characters';
2827 + $stored_name = (string) ($metasync_options['general']['white_label_plugin_name'] ?? '');
2828 + // Only enforce the 18-char limit when the value actually changes so an
2829 + // already-saved longer legacy name can be re-saved without an error.
2830 + if (strlen($value) > 18 && $value !== $stored_name && wp_unslash($value) !== $stored_name) {
2831 + $whitelabel_errors[$field] = 'Plugin name must not exceed 18 characters';
2452 2832 continue;
2453 2833 }
2454 2834 }
2455 2835
@@ -2456,25 +2836,32 @@
2456 2836 if ($field === 'white_label_plugin_menu_icon') {
2457 2837
2458 2838 if ($value === '') {
2459 2839 $metasync_options['general'][$field] = '';
2840 + } elseif (preg_match('/^dashicons-[a-z0-9-]+$/', $value)) {
2841 + // Accept dashicon names (e.g. dashicons-location) in addition to image URLs
2842 + $metasync_options['general'][$field] = sanitize_text_field($value);
2460 2843 } elseif (filter_var($value, FILTER_VALIDATE_URL)) {
2461 2844
2462 2845 $image_extensions = ['png', 'svg'];
2463 2846
2464 - $path = parse_url($value, PHP_URL_PATH);
2847 + $path = wp_parse_url($value, PHP_URL_PATH);
2465 2848 $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
2466 2849
2467 2850 if (in_array($extension, $image_extensions, true)) {
2468 2851 $metasync_options['general'][$field] = esc_url_raw($value);
2469 2852 } else {
2470 - $validation_errors[] = 'Invalid Menu icon format. Only PNG and SVG are allowed.';
2853 + $whitelabel_errors[$field] = 'Invalid Menu icon format. Only PNG, SVG, or a dashicon name (e.g. dashicons-location) are allowed.';
2471 2854 }
2472 2855 } else {
2473 - $validation_errors[] = 'Invalid Menu icon URL format.';
2856 + $whitelabel_errors[$field] = 'Invalid Menu icon URL format.';
2474 2857 }
2858 + } elseif ($field === 'white_label_plugin_menu_slug') {
2859 + // Self-correcting slug: a URL-shaped value is reduced to a valid WP
2860 + // menu slug so it can never break sidebar link generation.
2861 + $metasync_options['general'][$field] = sanitize_title($value);
2475 2862 } else {
2476 - $metasync_options['general'][$field] = sanitize_text_field($_POST['metasync_options']['general'][$field]);
2863 + $metasync_options['general'][$field] = sanitize_text_field(wp_unslash($_POST['metasync_options']['general'][$field]));
2477 2864 }
2478 2865 }
2479 2866 }
2480 2867
@@ -2481,9 +2868,9 @@
2481 2868 $textarea_fields = ['otto_bot_whitelist', 'otto_bot_blacklist'];
2482 2869 if ($general_tab_submitted) {
2483 2870 foreach ($textarea_fields as $field) {
2484 2871 if (isset($_POST['metasync_options']['general'][$field])) {
2485 - $metasync_options['general'][$field] = sanitize_textarea_field($_POST['metasync_options']['general'][$field]);
2872 + $metasync_options['general'][$field] = sanitize_textarea_field(wp_unslash($_POST['metasync_options']['general'][$field]));
2486 2873 } else {
2487 2874 $metasync_options['general'][$field] = '';
2488 2875 }
2489 2876 }
@@ -2491,13 +2878,23 @@
2491 2878
2492 2879 if ($general_tab_submitted) {
2493 2880 foreach ($bool_fields as $field) {
2494 2881 if (isset($_POST['metasync_options']['general'][$field])) {
2495 - $metasync_options['general'][$field] = filter_var($_POST['metasync_options']['general'][$field], FILTER_VALIDATE_BOOLEAN);
2882 + $metasync_options['general'][$field] = filter_var(wp_unslash($_POST['metasync_options']['general'][$field]), FILTER_VALIDATE_BOOLEAN);
2496 2883 }else {
2497 2884 $metasync_options['general'][$field] = false;
2498 2885 }
2499 2886 }
2887 +
2888 + // SEO priority is a checkbox that stores a string rather than a
2889 + // bool, so it cannot ride the loop above. Only the exact opt-in
2890 + // value is stored; an unchecked box (or any tampered payload)
2891 + // falls back to custom-first, which is the pre-existing behaviour.
2892 + $metasync_options['general']['seo_priority'] =
2893 + (isset($_POST['metasync_options']['general']['seo_priority'])
2894 + && $_POST['metasync_options']['general']['seo_priority'] === Metasync_Seo_Precedence::PRIORITY_OTTO)
2895 + ? Metasync_Seo_Precedence::PRIORITY_OTTO
2896 + : 'custom';
2500 2897 }
2501 2898
2502 2899 if ($general_tab_submitted || $whitelabel_tab_submitted) {
2503 2900 foreach ($url_fields as $field) {
@@ -2502,9 +2899,10 @@
2502 2899 if ($general_tab_submitted || $whitelabel_tab_submitted) {
2503 2900 foreach ($url_fields as $field) {
2504 2901 if (isset($_POST['metasync_options']['general'][$field])) {
2505 2902
2506 - $value = trim($_POST['metasync_options']['general'][$field]);
2903 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every storing branch below sanitises via sanitize_text_field/sanitize_title/esc_url_raw before the value reaches the option.
2904 + $value = trim(wp_unslash($_POST['metasync_options']['general'][$field]));
2507 2905
2508 2906 $whitelabel_clearable_url_fields = [
2509 2907 'white_label_plugin_author_uri',
2510 2908 'white_label_plugin_uri'
@@ -2520,9 +2918,9 @@
2520 2918 }
2521 2919
2522 2920 if (filter_var($value, FILTER_VALIDATE_URL)) {
2523 2921
2524 - $parsed_url = parse_url($value);
2922 + $parsed_url = wp_parse_url($value);
2525 2923 $host = isset($parsed_url['host']) ? $parsed_url['host'] : '';
2526 2924
2527 2925 if (strpos($host, '.') !== false || filter_var($host, FILTER_VALIDATE_IP)) {
2528 2926 $metasync_options['general'][$field] = esc_url_raw($value);
@@ -2539,42 +2937,92 @@
2539 2937 }
2540 2938
2541 2939 if ($general_tab_submitted) {
2542 2940 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']);
2941 + $metasync_options['general']['content_genius_sync_roles'] = array_map('sanitize_text_field', wp_unslash($_POST['metasync_options']['general']['content_genius_sync_roles']));
2544 2942 } else {
2545 2943 $metasync_options['general']['content_genius_sync_roles'] = array();
2546 2944 }
2547 2945 }
2548 2946
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();
2947 + // Breadcrumb settings live on their own admin page, which saves through
2948 + // options.php -> sanitize() rather than this AJAX handler. Only touch
2949 + // them here if the submitted form actually carried breadcrumb fields,
2950 + // otherwise a General-tab save would reset every breadcrumb option.
2951 + if ($general_tab_submitted
2952 + && isset($_POST['metasync_options']['breadcrumbs'])
2953 + && is_array($_POST['metasync_options']['breadcrumbs'])
2954 + ) {
2955 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each key is sanitised below (bool cast for flags, sanitize_text_field for the separator).
2956 + $breadcrumbs_input = wp_unslash($_POST['metasync_options']['breadcrumbs']);
2553 2957
2554 2958 if (!isset($metasync_options['breadcrumbs']) || !is_array($metasync_options['breadcrumbs'])) {
2555 2959 $metasync_options['breadcrumbs'] = array();
2556 2960 }
2557 2961
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}';
2962 + foreach (array('enabled', 'disable_schema', 'show_current_page') as $breadcrumb_flag) {
2963 + if (isset($breadcrumbs_input[$breadcrumb_flag])) {
2964 + $metasync_options['breadcrumbs'][$breadcrumb_flag] = !empty($breadcrumbs_input[$breadcrumb_flag]);
2965 + }
2966 + }
2967 + // An empty separator (Custom radio selected blank) keeps the
2968 + // stored value instead of rendering a blank gap between links.
2969 + if (isset($breadcrumbs_input['separator'])) {
2970 + $breadcrumb_separator = sanitize_text_field($breadcrumbs_input['separator']);
2971 + if ('' !== $breadcrumb_separator) {
2972 + $metasync_options['breadcrumbs']['separator'] = $breadcrumb_separator;
2973 + }
2974 + }
2975 + if (isset($breadcrumbs_input['home_label'])) {
2976 + $metasync_options['breadcrumbs']['home_label'] = sanitize_text_field($breadcrumbs_input['home_label']);
2977 + }
2978 + if (isset($breadcrumbs_input['home_url'])) {
2979 + $metasync_options['breadcrumbs']['home_url'] = esc_url_raw($breadcrumbs_input['home_url']);
2980 + }
2981 + if (isset($breadcrumbs_input['prefix_text'])) {
2982 + $metasync_options['breadcrumbs']['prefix_text'] = sanitize_text_field($breadcrumbs_input['prefix_text']);
2983 + }
2984 + if (isset($breadcrumbs_input['archive_label_format'])) {
2985 + $metasync_options['breadcrumbs']['archive_label_format'] = sanitize_text_field($breadcrumbs_input['archive_label_format']);
2986 + }
2566 2987 }
2567 2988
2989 + // Open Graph / article toggle checkboxes live under common_meta_settings.
2990 + // Unchecked HTML checkboxes are absent from $_POST, so we explicitly
2991 + // write 'false' for any missing toggle to persist opt-outs.
2992 + if ($general_tab_submitted) {
2993 + $og_toggle_fields = [
2994 + 'open_graph_meta_tags',
2995 + 'facebook_meta_tags',
2996 + 'twitter_meta_tags',
2997 + 'og_image_dimensions',
2998 + 'article_timestamps',
2999 + 'article_author',
3000 + 'article_section',
3001 + 'article_tags',
3002 + 'twitter_image_alt',
3003 + ];
3004 + if (!isset($metasync_options['common_meta_settings']) || !is_array($metasync_options['common_meta_settings'])) {
3005 + $metasync_options['common_meta_settings'] = [];
3006 + }
3007 + foreach ($og_toggle_fields as $field) {
3008 + $posted = isset($_POST['metasync_options']['common_meta_settings'][$field])
3009 + ? sanitize_text_field(wp_unslash($_POST['metasync_options']['common_meta_settings'][$field]))
3010 + : '';
3011 + $metasync_options['common_meta_settings'][$field] = ($posted === 'true') ? 'true' : 'false';
3012 + }
3013 + }
3014 +
2568 3015 if ($general_tab_submitted && isset($_POST['metasync_options']['general']['default_page_builder'])) {
2569 3016 require_once plugin_dir_path(dirname(__FILE__)) . 'custom-pages/class-metasync-html-to-builder-converter.php';
2570 3017 $available_builders = Metasync_HTML_To_Builder_Converter::get_available_builders();
2571 - $builder_value = sanitize_text_field($_POST['metasync_options']['general']['default_page_builder']);
3018 + $builder_value = sanitize_text_field(wp_unslash($_POST['metasync_options']['general']['default_page_builder']));
2572 3019 if (isset($available_builders[$builder_value]) && $available_builders[$builder_value]['detected']) {
2573 3020 $metasync_options['general']['default_page_builder'] = $builder_value;
2574 3021 } else {
2575 3022 $metasync_options['general']['default_page_builder'] = 'gutenberg';
2576 3023 if (isset($available_builders[$builder_value]) && !$available_builders[$builder_value]['detected']) {
3024 + /* translators: %s: page builder name. */
2577 3025 $validation_errors[] = sprintf(__('Builder "%s" is not detected on this site. Reverted to Gutenberg.', 'metasync'), $available_builders[$builder_value]['label']);
2578 3026 }
2579 3027 }
2580 3028 }
@@ -2579,10 +3027,13 @@
2579 3027 }
2580 3028 }
2581 3029
2582 3030 if ($whitelabel_tab_submitted && isset($_POST['metasync_options']['whitelabel'])) {
2583 - $gp = isset($_POST['metasync_options']['general']) && is_array($_POST['metasync_options']['general']) ? $_POST['metasync_options']['general'] : [];
2584 - $wp = isset($_POST['metasync_options']['whitelabel']) && is_array($_POST['metasync_options']['whitelabel']) ? $_POST['metasync_options']['whitelabel'] : [];
3031 + // Values are only previewed from the just-sanitised $metasync_options below; nothing here is stored.
3032 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- preview-only read
3033 + $gp = isset($_POST['metasync_options']['general']) && is_array($_POST['metasync_options']['general']) ? wp_unslash($_POST['metasync_options']['general']) : [];
3034 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- preview-only read
3035 + $wp = isset($_POST['metasync_options']['whitelabel']) && is_array($_POST['metasync_options']['whitelabel']) ? wp_unslash($_POST['metasync_options']['whitelabel']) : [];
2585 3036 $plugin_name = isset($gp['white_label_plugin_name']) ? trim((string) $gp['white_label_plugin_name']) : '';
2586 3037 $logo_light = isset($wp['logo_light']) ? trim((string) $wp['logo_light']) : '';
2587 3038 $logo_dark = isset($wp['logo_dark']) ? trim((string) $wp['logo_dark']) : '';
2588 3039 $author = isset($gp['white_label_plugin_author']) ? trim((string) $gp['white_label_plugin_author']) : '';
@@ -2599,8 +3050,9 @@
2599 3050 }
2600 3051 }
2601 3052
2602 3053 if (!empty($validation_errors)) {
3054 + $this->force_release_settings_recovery_lock();
2603 3055 wp_send_json_error([
2604 3056 'errors' => $validation_errors
2605 3057 ]);
2606 3058 return;
@@ -2606,46 +3058,91 @@
2606 3058 return;
2607 3059 }
2608 3060
2609 3061 $old_options = Metasync::get_option('general') ?? [];
2610 - $old_api_key = $old_options['searchatlas_api_key'] ?? '';
3062 + // Stored value is encrypted at rest (or legacy plaintext/empty). Keep it as-is
3063 + // for preserve/revert, and decrypt a separate copy for plaintext comparison.
3064 + $old_stored_api_key = $old_options['searchatlas_api_key'] ?? '';
3065 + $old_api_key = Metasync::get_searchatlas_api_key();
3066 + if ( $old_api_key === false ) {
3067 + // Stored key could not be decrypted (salts changed). Treat as no usable key.
3068 + $old_api_key = '';
3069 + }
2611 3070
2612 3071 $api_key_field_present = isset( $_POST['metasync_options']['general']['searchatlas_api_key'] );
2613 3072 $api_key_validated = null; // null = not checked, true = valid, false = rejected
2614 3073 $is_connected = null;
2615 3074 $api_key_warning = null;
2616 - // Value from $metasync_options after the sanitize loop (sanitize_text_field applied at line ~2474)
2617 - $proposed_new_api_key = $metasync_options['general']['searchatlas_api_key'] ?? '';
2618 3075
2619 - // Only validate when the API key actually changed (new or modified)
3076 + // The submitted key arrives as plaintext; capture it for comparison before
3077 + // encrypting it for storage. When the field is absent, preserve the existing
3078 + // encrypted value untouched so a normal settings save never disturbs the key.
3079 + $proposed_new_api_key = $api_key_field_present ? ( $metasync_options['general']['searchatlas_api_key'] ?? '' ) : '';
3080 + if ( $api_key_field_present ) {
3081 + // encrypt_api_key('') === '' so this also handles key removal.
3082 + $metasync_options['general']['searchatlas_api_key'] = Metasync::encrypt_api_key( $proposed_new_api_key );
3083 + } else {
3084 + $metasync_options['general']['searchatlas_api_key'] = $old_stored_api_key;
3085 + }
3086 +
3087 + // When the API key changes, save it first, then POST heartbeat to register
3088 + // the WPWebsiteHeartbeat row on the backend, then GET ping to verify.
3089 + // The old flow did GET ping first, which failed because the row didn't exist yet.
2620 3090 if ( $api_key_field_present && $proposed_new_api_key !== '' && $proposed_new_api_key !== $old_api_key ) {
2621 - $ping = Metasync_Heartbeat_Manager::instance()->validate_api_key( $proposed_new_api_key );
3091 + // Step 1: Save the new (encrypted) key so SyncCustomerParams can use it
3092 + Metasync::set_option($metasync_options);
3093 + Metasync::invalidate_api_key_cache();
2622 3094
2623 - if ( $ping['connected'] === true ) {
2624 - // Key validated — accept it and auto-populate UUID if returned
2625 - $api_key_validated = true;
2626 - $is_connected = true;
2627 - if ( ! empty( $ping['otto_pixel_uuid'] ) ) {
2628 - $existing_uuid = $metasync_options['general']['otto_pixel_uuid'] ?? '';
2629 - if ( empty( $existing_uuid ) || $existing_uuid !== $ping['otto_pixel_uuid'] ) {
2630 - $metasync_options['general']['otto_pixel_uuid'] = sanitize_text_field( $ping['otto_pixel_uuid'] );
3095 + // Step 2: POST heartbeat to register the site (creates WPWebsiteHeartbeat row)
3096 + $sync_request = new Metasync_Sync_Requests();
3097 + $sync_response = $sync_request->SyncCustomerParams();
3098 + $sync_status = Metasync_Sync_Requests::get_response_code($sync_response);
3099 +
3100 + if ( $sync_status == 200 ) {
3101 + // Step 3: GET ping to verify registration succeeded
3102 + $ping = Metasync_Heartbeat_Manager::instance()->validate_api_key( $proposed_new_api_key );
3103 +
3104 + if ( $ping['connected'] === true ) {
3105 + $api_key_validated = true;
3106 + $is_connected = true;
3107 + if ( ! empty( $ping['otto_pixel_uuid'] ) ) {
3108 + $existing_uuid = $metasync_options['general']['otto_pixel_uuid'] ?? '';
3109 + if ( empty( $existing_uuid ) || $existing_uuid !== $ping['otto_pixel_uuid'] ) {
3110 + $metasync_options['general']['otto_pixel_uuid'] = sanitize_text_field( $ping['otto_pixel_uuid'] );
3111 + Metasync::set_option($metasync_options);
3112 + }
2631 3113 }
3114 + } else {
3115 + // POST succeeded but ping didn't confirm — keep the key, warn user
3116 + $api_key_validated = true;
3117 + $is_connected = true;
3118 + $api_key_warning = 'API key saved and heartbeat sent. Connection verification will complete on the next heartbeat.';
2632 3119 }
2633 - } elseif ( isset( $ping['network_error'] ) && $ping['network_error'] === true ) {
2634 - // Network/timeout failure — save the key but warn the user
2635 - $api_key_warning = 'Could not verify API key (network error). The key was saved — it will be verified on the next heartbeat.';
3120 + } elseif ( is_wp_error($sync_response) ) {
3121 + // Network error during POST — keep the key, warn user
3122 + $api_key_warning = 'Could not register with the server (network error). The key was saved — it will be verified on the next heartbeat.';
2636 3123 } else {
2637 - // Backend explicitly rejected the key — revert to old key
2638 - $metasync_options['general']['searchatlas_api_key'] = $old_api_key;
3124 + // POST returned non-200 — the key is likely invalid, revert to the
3125 + // previously stored (already-encrypted) value.
3126 + $metasync_options['general']['searchatlas_api_key'] = $old_stored_api_key;
3127 + Metasync::set_option($metasync_options);
3128 + Metasync::invalidate_api_key_cache();
2639 3129 $api_key_validated = false;
2640 3130 $is_connected = false;
2641 - $api_key_warning = 'The API key could not be verified. Please check it and try again.';
3131 + $api_key_warning = empty( $old_api_key )
3132 + ? 'The API key could not be verified. Please check it and try again.'
3133 + : 'The API key could not be verified. Your API key has been reverted to the previously saved key.';
2642 3134 }
3135 + } else {
3136 + // Key unchanged, removed, or field absent — just save options.
3137 + // $metasync_options already holds the correct stored value: the
3138 + // re-encrypted submission (present) or the preserved encrypted value
3139 + // (absent); removal stored encrypt_api_key('') === ''.
3140 + Metasync::set_option($metasync_options);
3141 + Metasync::invalidate_api_key_cache();
2643 3142 }
2644 3143
2645 - Metasync::set_option($metasync_options);
2646 -
2647 - // Update connection state immediately when ping confirmed
3144 + // Update connection state immediately when validated
2648 3145 if ( $api_key_validated === true ) {
2649 3146 update_option( 'metasync_last_known_connection_state', true );
2650 3147 set_transient( 'metasync_heartbeat_status_cache', [
2651 3148 'status' => true,
@@ -2654,35 +3151,38 @@
2654 3151 'updated_by' => 'manual_save_ping_connected',
2655 3152 ], 300 );
2656 3153 }
2657 3154
2658 - $data = Metasync::get_option('general');
2659 - $new_api_key = $data['searchatlas_api_key'] ?? '';
3155 + // Compare decrypted plaintext values — the stored value is ciphertext.
3156 + Metasync::invalidate_api_key_cache();
3157 + $new_api_key = Metasync::get_searchatlas_api_key();
3158 + if ( $new_api_key === false ) {
3159 + $new_api_key = '';
3160 + }
2660 3161
2661 3162 $api_key_changed = $old_api_key !== $new_api_key;
2662 3163 $api_key_added = empty($old_api_key) && !empty($new_api_key);
2663 3164 $api_key_removed = !empty($old_api_key) && empty($new_api_key);
2664 3165
2665 - // Skip heavy SyncCustomerParams when ping already validated the key — schedule
2666 - // an async heartbeat instead so the save response returns quickly.
2667 3166 if ( $api_key_validated === true ) {
2668 - $dt = new DateTime();
3167 + # Use current_time('mysql') so the value is consistent with the
3168 + # cron heartbeat writer and renders correctly via time_elapsed_string.
2669 3169 $send_auth_token_timestamp = Metasync::get_option();
2670 - $send_auth_token_timestamp['general']['send_auth_token_timestamp'] = $dt->format('M d, Y h:i:s A');
3170 + $send_auth_token_timestamp['general']['send_auth_token_timestamp'] = current_time('mysql');
2671 3171 Metasync::set_option($send_auth_token_timestamp);
2672 3172
2673 3173 Metasync_Heartbeat_Manager::instance()->maybe_schedule_heartbeat_cron();
2674 3174 do_action('metasync_trigger_immediate_heartbeat', 'Manual API key update - validated via ping');
2675 3175 } else {
2676 - // No ping was done (key unchanged, cleared, or network failure) — fall back to sync
3176 + // Key unchanged or removed — run sync as before
2677 3177 $sync_request = new Metasync_Sync_Requests();
2678 3178 $response = $sync_request->SyncCustomerParams();
2679 - $responseCode = wp_remote_retrieve_response_code($response);
3179 + $responseCode = Metasync_Sync_Requests::get_response_code($response);
2680 3180
2681 3181 if ($responseCode == 200) {
2682 - $dt = new DateTime();
3182 + # Use current_time('mysql') for consistency with cron heartbeat.
2683 3183 $send_auth_token_timestamp = Metasync::get_option();
2684 - $send_auth_token_timestamp['general']['send_auth_token_timestamp'] = $dt->format('M d, Y h:i:s A');
3184 + $send_auth_token_timestamp['general']['send_auth_token_timestamp'] = current_time('mysql');
2685 3185 Metasync::set_option($send_auth_token_timestamp);
2686 3186
2687 3187 if ($api_key_added) {
2688 3188 Metasync_Heartbeat_Manager::instance()->maybe_schedule_heartbeat_cron();
@@ -2697,9 +3197,10 @@
2697 3197 }
2698 3198
2699 3199 if (isset($_POST['metasync_options']['whitelabel'])) {
2700 3200
2701 - $whitelabel_data = $_POST['metasync_options']['whitelabel'];
3201 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every stored key below is validated/sanitised (FILTER_VALIDATE_URL + esc_url_raw for logos, sanitize_* for text).
3202 + $whitelabel_data = wp_unslash($_POST['metasync_options']['whitelabel']);
2702 3203 $existing_whitelabel = $metasync_options['whitelabel'] ?? [];
2703 3204
2704 3205 if (isset($whitelabel_data['logo'])) {
2705 3206 $logo_value = trim($whitelabel_data['logo']);
@@ -2740,9 +3241,9 @@
2740 3241 }
2741 3242 }
2742 3243
2743 3244 if (isset($whitelabel_data['settings_password'])) {
2744 - $password_value = trim($whitelabel_data['settings_password']);
3245 + $password_value = (string) $whitelabel_data['settings_password'];
2745 3246
2746 3247 $hide_settings_enabled = isset($whitelabel_data['hide_settings']) && $whitelabel_data['hide_settings'] == '1';
2747 3248
2748 3249 if ($hide_settings_enabled && empty($password_value)) {
@@ -2748,10 +3249,18 @@
2748 3249 if ($hide_settings_enabled && empty($password_value)) {
2749 3250 if (isset($existing_whitelabel['settings_password'])) {
2750 3251 $metasync_options['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'];
2751 3252 }
3253 + } elseif ($password_value === '') {
3254 + $metasync_options['whitelabel']['settings_password'] = '';
2752 3255 } else {
2753 - $metasync_options['whitelabel']['settings_password'] = sanitize_text_field($password_value);
3256 + $encrypted_password = Metasync::encrypt_secret($password_value);
3257 + if (Metasync::is_encrypted_secret($encrypted_password) && !hash_equals($password_value, $encrypted_password)) {
3258 + $metasync_options['whitelabel']['settings_password'] = $encrypted_password;
3259 + } else {
3260 + $metasync_options['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'] ?? '';
3261 + $whitelabel_errors['settings_password'] = 'Unable to securely save the password. Please choose a different password.';
3262 + }
2754 3263 }
2755 3264 } else {
2756 3265 if (isset($existing_whitelabel['settings_password'])) {
2757 3266 $metasync_options['whitelabel']['settings_password'] = $existing_whitelabel['settings_password'];
@@ -2832,9 +3341,9 @@
2832 3341
2833 3342 }
2834 3343
2835 3344 $redirect_url = isset($_GET['tab']) ?
2836 - admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-settings&tab='.$_GET['tab']) :
3345 + admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-settings&tab=' . sanitize_text_field(wp_unslash($_GET['tab']))) :
2837 3346 admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-settings');
2838 3347
2839 3348 // Also save edge cache settings if present in the payload (section renders inside this form)
2840 3349 if (class_exists('Metasync_Edge_Cache_Settings')) {
@@ -2841,8 +3350,9 @@
2841 3350 Metasync_Edge_Cache_Settings::save_from_post();
2842 3351 }
2843 3352
2844 3353 if ($general_tab_submitted && isset($_POST['metasync_llms_txt_settings'])) {
3354 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitised by sanitize_llms_txt_settings() before storage.
2845 3355 $llms_input = wp_unslash($_POST['metasync_llms_txt_settings']);
2846 3356 if (is_array($llms_input)) {
2847 3357 update_option('metasync_llms_txt_settings', $this->sanitize_llms_txt_settings($llms_input));
2848 3358 }
@@ -2852,8 +3362,20 @@
2852 3362 'message' => 'Settings saved successfully!',
2853 3363 'redirect_url' => $redirect_url,
2854 3364 );
2855 3365
3366 + // Per-field whitelabel validation issues (non-blocking): the save succeeded for all
3367 + // other fields, but the user must be told which values were rejected.
3368 + if (!empty($whitelabel_errors)) {
3369 + $response_payload['whitelabel_errors'] = $whitelabel_errors;
3370 + }
3371 +
3372 + // Final slug after sanitization so the client redirects to the page WP actually
3373 + // registered, even when the submitted value was cleaned up.
3374 + if (isset($_POST['metasync_options']['general']['white_label_plugin_menu_slug'])) {
3375 + $response_payload['saved_menu_slug'] = $metasync_options['general']['white_label_plugin_menu_slug'] ?? '';
3376 + }
3377 +
2856 3378 if ( $api_key_field_present && $proposed_new_api_key !== '' && $proposed_new_api_key !== $old_api_key ) {
2857 3379 if ( $api_key_validated !== null ) {
2858 3380 $response_payload['api_key_validated'] = $api_key_validated;
2859 3381 $response_payload['is_connected'] = $is_connected;
@@ -2860,12 +3382,20 @@
2860 3382 }
2861 3383 if ( $api_key_warning !== null ) {
2862 3384 $response_payload['warning'] = $api_key_warning;
2863 3385 }
3386 + // Note: the previous key is intentionally NOT returned. The key is now
3387 + // managed via one-click auth and shown masked, so the cleartext value
3388 + // must never be sent to the browser.
3389 + } elseif ( $api_key_field_present && $proposed_new_api_key === '' && $old_api_key !== '' ) {
3390 + $response_payload['api_key_removed'] = true;
3391 + $response_payload['warning'] = 'The API key has been removed. The plugin will no longer sync with the server.';
2864 3392 }
2865 3393
2866 3394 delete_transient('metasync_otto_js_detected');
2867 3395
3396 + $this->force_release_settings_recovery_lock();
3397 +
2868 3398 wp_send_json_success( $response_payload );
2869 3399 }
2870 3400
2871 3401 /**
@@ -2874,9 +3404,10 @@
2874 3404 * Moved from Metasync_Admin – the admin class now delegates here.
2875 3405 */
2876 3406 public function ajax_save_execution_settings()
2877 3407 {
2878 - if (!isset($_POST['execution_settings_nonce']) || !wp_verify_nonce($_POST['execution_settings_nonce'], 'metasync_execution_settings_nonce')) {
3408 + $execution_nonce = isset($_POST['execution_settings_nonce']) ? sanitize_text_field(wp_unslash($_POST['execution_settings_nonce'])) : '';
3409 + if (!wp_verify_nonce($execution_nonce, 'metasync_execution_settings_nonce')) {
2879 3410 wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
2880 3411 return;
2881 3412 }
2882 3413
@@ -2884,8 +3415,10 @@
2884 3415 wp_send_json_error(array('message' => 'Insufficient permissions to save settings.'));
2885 3416 return;
2886 3417 }
2887 3418
3419 + $existing_settings = get_option('metasync_execution_settings', array());
3420 +
2888 3421 $settings = array(
2889 3422 'max_execution_time' => isset($_POST['max_execution_time']) ? absint($_POST['max_execution_time']) : 30,
2890 3423 'max_memory_limit' => isset($_POST['max_memory_limit']) ? absint($_POST['max_memory_limit']) : 256,
2891 3424 'log_batch_size' => isset($_POST['log_batch_size']) ? absint($_POST['log_batch_size']) : 1000,
@@ -2890,8 +3423,9 @@
2890 3423 'max_memory_limit' => isset($_POST['max_memory_limit']) ? absint($_POST['max_memory_limit']) : 256,
2891 3424 'log_batch_size' => isset($_POST['log_batch_size']) ? absint($_POST['log_batch_size']) : 1000,
2892 3425 'action_scheduler_batches' => isset($_POST['action_scheduler_batches']) ? absint($_POST['action_scheduler_batches']) : 1,
2893 3426 'otto_rate_limit' => isset($_POST['otto_rate_limit']) ? absint($_POST['otto_rate_limit']) : 10,
3427 + 'otto_cache_ttl' => isset($existing_settings['otto_cache_ttl']) ? absint($existing_settings['otto_cache_ttl']) : 30,
2894 3428 'queue_cleanup_days' => isset($_POST['queue_cleanup_days']) ? absint($_POST['queue_cleanup_days']) : 31
2895 3429 );
2896 3430
2897 3431 $server_limits = Metasync_Settings_Fields::instance()->get_server_limits();
@@ -2906,20 +3440,20 @@
2906 3440 return;
2907 3441 }
2908 3442
2909 3443 $can_change_memory = Metasync_Settings_Fields::instance()->can_change_memory_limit();
2910 - if ($can_change_memory && ($settings['max_memory_limit'] < 64 || $settings['max_memory_limit'] > 512)) {
2911 - wp_send_json_error(array('message' => 'Max Memory Limit must be between 64 and 512 MB.'));
3444 + $memory_range = Metasync_Settings_Fields::instance()->get_memory_limit_allowed_range();
3445 + if ($can_change_memory && $settings['max_memory_limit'] < $memory_range['min']) {
3446 + wp_send_json_error(array('message' => sprintf('Max Memory Limit must be at least %d MB.', $memory_range['min'])));
2912 3447 return;
2913 3448 }
2914 3449
2915 - if ($can_change_memory && $server_limits['memory_limit_raw'] != -1 && $settings['max_memory_limit'] > $server_limits['memory_limit_raw']) {
2916 - 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'])));
3450 + if ($can_change_memory && $memory_range['max'] !== null && $settings['max_memory_limit'] > $memory_range['max']) {
3451 + wp_send_json_error(array('message' => sprintf('Max Memory Limit must be between %d and %d MB.', $memory_range['min'], $memory_range['max'])));
2917 3452 return;
2918 3453 }
2919 3454
2920 3455 if (!$can_change_memory) {
2921 - $existing_settings = get_option('metasync_execution_settings', array());
2922 3456 $settings['max_memory_limit'] = isset($existing_settings['max_memory_limit'])
2923 3457 ? $existing_settings['max_memory_limit']
2924 3458 : 256;
2925 3459 }
@@ -2943,8 +3477,21 @@
2943 3477 wp_send_json_error(array('message' => 'Queue Auto-Cleanup Days must be between 7 and 90 days.'));
2944 3478 return;
2945 3479 }
2946 3480
3481 + // update_option() returns false both on real failure AND when the
3482 + // submitted values are identical to what's already stored (a no-op
3483 + // write). An unchanged save is not an error, so short-circuit to
3484 + // success when nothing actually changed. This also covers the case
3485 + // where the form fires twice in quick succession: the second request
3486 + // sees no change and would otherwise report a spurious failure.
3487 + if ($settings == $existing_settings) {
3488 + wp_send_json_success(array(
3489 + 'message' => 'Execution settings saved successfully!'
3490 + ));
3491 + return;
3492 + }
3493 +
2947 3494 $saved = update_option('metasync_execution_settings', $settings);
2948 3495
2949 3496 if ($saved !== false) {
2950 3497 wp_send_json_success(array(
@@ -2954,9 +3501,552 @@
2954 3501 wp_send_json_error(array('message' => 'Failed to save settings. Please try again.'));
2955 3502 }
2956 3503 }
2957 3504
3505 + # ------------------------------------------------------------------
3506 + # Headless Mode settings on the Advanced tab
3507 + # ------------------------------------------------------------------
3508 + # Metasync_Headless_Config is the only sanitiser for these four values.
3509 + # Nothing here validates a domain, a policy or a prefix — it collects
3510 + # what was submitted, hands it to update_settings(), and then shows back
3511 + # whatever that stored, so a value the sanitiser refused reads as refused
3512 + # instead of being echoed back as though it saved.
3513 + # ------------------------------------------------------------------
3514 +
2958 3515 /**
3516 + * Field prefix shared by every control in the Headless Mode block.
3517 + *
3518 + * The controls sit inside the Advanced tab's outer form, which posts to
3519 + * options.php. Naming them outside `metasync_options[...]` keeps them out
3520 + * of sanitize() entirely, so an unrelated save of that form can neither
3521 + * store them unsanitised nor clear them.
3522 + */
3523 + const HEADLESS_FIELD_PREFIX = 'metasync_headless_';
3524 +
3525 + /**
3526 + * Nonce action and request field for the Headless Mode save.
3527 + */
3528 + const HEADLESS_NONCE_ACTION = 'metasync_headless_settings_nonce';
3529 + const HEADLESS_NONCE_FIELD = 'headless_settings_nonce';
3530 +
3531 + /**
3532 + * Render the Headless Mode section of the Advanced settings accordion.
3533 + *
3534 + * Values come from Metasync_Headless_Config::get_settings(), which is the
3535 + * sanitised stored state rather than anything the operator typed.
3536 + *
3537 + * @return void
3538 + */
3539 + public function render_headless_mode_section()
3540 + {
3541 + $settings = Metasync_Headless_Config::get_settings();
3542 + $enabled = !empty($settings['enabled']);
3543 + $active = Metasync_Headless_Config::is_active();
3544 + $prefix = self::HEADLESS_FIELD_PREFIX;
3545 +
3546 + $policy_labels = array(
3547 + Metasync_Headless_Config::SLASH_PRESERVE => 'Preserve — hand back whatever WordPress produced (default)',
3548 + Metasync_Headless_Config::SLASH_STRIP => 'Strip — no trailing slash',
3549 + Metasync_Headless_Config::SLASH_ADD => 'Add — always end with a trailing slash',
3550 + );
3551 + ?>
3552 + <div style="background: var(--dashboard-card-bg); padding: 20px; border-radius: 8px;">
3553 + <p style="color: var(--dashboard-text-secondary); margin: 0 0 20px 0;">
3554 + Headless Mode describes a separate frontend that serves the public site, so this plugin can build
3555 + URLs on that host instead of this one. With the mode off nothing here changes how the site behaves.
3556 + </p>
3557 +
3558 + <div style="background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.3); border-radius: 8px; padding: 16px; margin-bottom: 20px;">
3559 + <p style="color: var(--dashboard-text-secondary); margin: 0;">
3560 + Headless Mode is for sites where WordPress manages content and a separate frontend serves the public pages.
3561 + Set the public frontend URL below, then choose how MetaSync should build those public URLs.
3562 + </p>
3563 + </div>
3564 +
3565 + <div id="metasync-headless-settings-message" style="display: none; margin-bottom: 20px;"></div>
3566 +
3567 + <form id="metasync-headless-settings-form" method="post" data-headless-active="<?php echo $active ? '1' : '0'; ?>">
3568 + <?php wp_nonce_field(self::HEADLESS_NONCE_ACTION, self::HEADLESS_NONCE_FIELD); ?>
3569 +
3570 + <div style="background: var(--dashboard-card-bg-alt, rgba(255,255,255,0.05)); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
3571 + <label for="<?php echo esc_attr($prefix . 'enabled'); ?>" style="display: flex; align-items: center; gap: 10px; color: var(--dashboard-text-primary); font-weight: 500; cursor: pointer;">
3572 + <?php
3573 + # Hidden field first, checkbox second, same name. An unchecked
3574 + # checkbox posts nothing at all, and normalize() reads an absent
3575 + # `enabled` key as false — so without this pair, a submission that
3576 + # simply did not include the checkbox would switch the mode off as
3577 + # a side effect. With it, "off" arrives as an explicit 0.
3578 + ?>
3579 + <input type="hidden" name="<?php echo esc_attr($prefix . 'enabled'); ?>" value="0" />
3580 + <input type="checkbox"
3581 + id="<?php echo esc_attr($prefix . 'enabled'); ?>"
3582 + name="<?php echo esc_attr($prefix . 'enabled'); ?>"
3583 + value="1"
3584 + style="width: 18px; height: 18px; cursor: pointer;"
3585 + <?php checked($enabled, true); ?> />
3586 + <span>Enable Headless Mode</span>
3587 + </label>
3588 +
3589 + <div style="background: rgba(245, 158, 11, 0.1); border: 1px solid rgba(245, 158, 11, 0.3); border-radius: 8px; padding: 16px; margin: 16px 0 0 0;">
3590 + <p style="color: var(--dashboard-text-secondary); margin: 0; font-size: 13px;">
3591 + <strong style="color: var(--dashboard-warning, #f59e0b);">Changing this can affect your public frontend.</strong><br />
3592 + When Headless Mode is active, the GraphQL SEO field and generated URLs follow the settings below.
3593 + Coordinate with whoever maintains the frontend before disabling it or changing its public URL.
3594 + </p>
3595 + </div>
3596 +
3597 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 12px 0 0 0;">
3598 + Current state:
3599 + <strong style="color: var(--dashboard-text-primary);"><?php echo $enabled ? 'enabled' : 'disabled'; ?></strong><?php
3600 + if ($enabled && !$active) {
3601 + ?> &mdash; but not yet in effect: a frontend domain is required before any headless URL can be built.<?php
3602 + }
3603 + ?>
3604 + </p>
3605 + </div>
3606 +
3607 + <div style="background: var(--dashboard-card-bg-alt, rgba(255,255,255,0.05)); border: 1px solid var(--dashboard-border); border-radius: 8px; padding: 20px; margin-bottom: 20px;">
3608 + <div style="margin-bottom: 24px;">
3609 + <label for="<?php echo esc_attr($prefix . 'frontend_domain'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px; font-weight: 500;">
3610 + Public frontend URL:
3611 + </label>
3612 + <input type="url"
3613 + id="<?php echo esc_attr($prefix . 'frontend_domain'); ?>"
3614 + name="<?php echo esc_attr($prefix . 'frontend_domain'); ?>"
3615 + value="<?php echo esc_attr($settings['frontend_domain']); ?>"
3616 + placeholder="https://www.example.com"
3617 + style="width: 100%; max-width: 420px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg); color: var(--dashboard-text-primary);" />
3618 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3619 + Scheme and host only &mdash; for example <code>https://www.example.com</code>. A path is
3620 + refused: <code>https://example.com/blog</code> will not save, because only per-post-type
3621 + prefixes exist and a shared path segment could never be put back on terms, archives or
3622 + the homepage. Use a path prefix below instead. A bare host is treated as https.
3623 + </p>
3624 + </div>
3625 +
3626 + <div style="margin-bottom: 24px;">
3627 + <label for="<?php echo esc_attr($prefix . 'slash_policy'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px; font-weight: 500;">
3628 + Trailing Slash Policy:
3629 + </label>
3630 + <select id="<?php echo esc_attr($prefix . 'slash_policy'); ?>"
3631 + name="<?php echo esc_attr($prefix . 'slash_policy'); ?>"
3632 + style="max-width: 420px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg); color: var(--dashboard-text-primary);">
3633 + <?php foreach (Metasync_Headless_Config::slash_policies() as $policy) { ?>
3634 + <option value="<?php echo esc_attr($policy); ?>" <?php selected($settings['slash_policy'], $policy); ?>>
3635 + <?php echo esc_html(isset($policy_labels[$policy]) ? $policy_labels[$policy] : $policy); ?>
3636 + </option>
3637 + <?php } ?>
3638 + </select>
3639 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3640 + Choose how generated public URLs end. For example, <code>/about</code> stays unchanged with Preserve,
3641 + becomes <code>/about</code> with Strip, or <code>/about/</code> with Add. Match your frontend router.
3642 + </p>
3643 + </div>
3644 +
3645 + <div>
3646 + <label for="<?php echo esc_attr($prefix . 'path_prefixes'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px; font-weight: 500;">
3647 + Path Prefixes:
3648 + </label>
3649 + <textarea id="<?php echo esc_attr($prefix . 'path_prefixes'); ?>"
3650 + name="<?php echo esc_attr($prefix . 'path_prefixes'); ?>"
3651 + rows="4"
3652 + placeholder="post = blog"
3653 + style="width: 100%; max-width: 420px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg); color: var(--dashboard-text-primary); font-family: monospace;"><?php echo esc_textarea(self::format_headless_path_prefixes($settings['path_prefixes'])); ?></textarea>
3654 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3655 + Map WordPress content types to frontend URL folders:
3656 + </p>
3657 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 8px 0 0 0;">
3658 + One per line, for example <code>post = blog</code> produces <code>/blog/example-post</code>.
3659 + Leave empty when your frontend uses the same paths as WordPress.
3660 + </p>
3661 + </div>
3662 +
3663 + <details style="margin-top: 24px;">
3664 + <summary style="color: var(--dashboard-text-primary); font-weight: 500; cursor: pointer;">Advanced refresh settings</summary>
3665 + <div style="padding-top: 16px;">
3666 + <label for="<?php echo esc_attr($prefix . 'refresh_interval_minutes'); ?>" style="display: block; color: var(--dashboard-text-primary); margin-bottom: 8px;">
3667 + Background refresh interval (minutes):
3668 + </label>
3669 + <input type="number"
3670 + id="<?php echo esc_attr($prefix . 'refresh_interval_minutes'); ?>"
3671 + name="<?php echo esc_attr($prefix . 'refresh_interval_minutes'); ?>"
3672 + value="<?php echo esc_attr($settings['refresh_interval_minutes']); ?>"
3673 + min="<?php echo esc_attr((string) Metasync_Headless_Config::REFRESH_INTERVAL_MIN_MINUTES); ?>"
3674 + max="<?php echo esc_attr((string) Metasync_Headless_Config::REFRESH_INTERVAL_MAX_MINUTES); ?>"
3675 + step="1"
3676 + style="width: 100%; max-width: 200px; padding: 8px; border: 1px solid var(--dashboard-border); border-radius: 6px; background: var(--dashboard-card-bg); color: var(--dashboard-text-primary);" />
3677 + <p style="color: var(--dashboard-text-secondary); font-size: 12px; margin: 4px 0 0 0;">
3678 + How often the background job re-checks a URL's stored OTTO data against OTTO directly, for
3679 + any post or term OTTO's deployment webhook missed. Does not affect the webhook, which stays
3680 + the primary, immediate update path. Between
3681 + <?php echo esc_html((string) Metasync_Headless_Config::REFRESH_INTERVAL_MIN_MINUTES); ?> and
3682 + <?php echo esc_html((string) Metasync_Headless_Config::REFRESH_INTERVAL_MAX_MINUTES); ?> minutes;
3683 + defaults to <?php echo esc_html((string) Metasync_Headless_Config::REFRESH_INTERVAL_DEFAULT_MINUTES); ?>.
3684 + </p>
3685 + </div>
3686 + </details>
3687 + </div>
3688 +
3689 + <button type="button"
3690 + id="metasync-headless-settings-save-btn"
3691 + class="metasync-btn-primary"
3692 + style="background: var(--dashboard-gradient-primary); color: #ffffff; border: none; padding: 12px 24px; border-radius: 8px; font-weight: 500; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);">
3693 + <span class="save-text">Save Headless Settings</span>
3694 + </button>
3695 + </form>
3696 + </div>
3697 + <script>
3698 + jQuery(document).ready(function ($) {
3699 + // Click, not submit: this form is nested inside #metaSyncGeneralSetting,
3700 + // so the browser drops the inner form tag and a submit handler would
3701 + // never fire on its own.
3702 + var $saveBtn = $('#metasync-headless-settings-save-btn');
3703 + var $message = $('#metasync-headless-settings-message');
3704 + var headlessModeWasActive = <?php echo $active ? 'true' : 'false'; ?>;
3705 + var headlessDisableWarning = 'Turning off Headless Mode removes the metasyncSeo field from the GraphQL schema. Any frontend querying it will error until it is redeployed. Continue?';
3706 + var headlessDomainWarning = 'The metasyncSeo field stays in the GraphQL schema, but every URL it returns will be empty until a frontend domain is set again. Continue?';
3707 + var headlessModeSavedSettings = <?php echo wp_json_encode(array(
3708 + 'enabled' => !empty($settings['enabled']),
3709 + 'frontend_domain' => $settings['frontend_domain'],
3710 + 'slash_policy' => $settings['slash_policy'],
3711 + 'path_prefixes' => self::format_headless_path_prefixes($settings['path_prefixes']),
3712 + 'refresh_interval_minutes' => $settings['refresh_interval_minutes'],
3713 + )); ?>;
3714 +
3715 + // This mirrors normalize_domain()'s shape rules so a value the
3716 + // server will refuse cannot look active here. Keep this deliberately
3717 + // small: it is a confirmation decision, not a client-side validator,
3718 + // and the server remains authoritative. Mirrors can drift.
3719 + function isUsableFrontendDomain(value) {
3720 + // PHP's trim() strips only space, tab, newline, carriage return,
3721 + // NUL and vertical tab, while $.trim() and String.trim() strip the
3722 + // whole Unicode set. Trimming a non-breaking space or BOM that the
3723 + // server keeps would make a pasted domain look usable while
3724 + // normalize_domain() rejects it — the guard stays silent and the
3725 + // mode deactivates anyway. Match the server charlist exactly.
3726 + var candidate = (value || '').replace(/^[ \t\n\r\0\x0B]+|[ \t\n\r\0\x0B]+$/g, '');
3727 + var parsed;
3728 + var authority;
3729 + var host;
3730 + var path;
3731 +
3732 + if (candidate === '') {
3733 + return false;
3734 + }
3735 +
3736 + if (candidate.indexOf('//') === -1) {
3737 + candidate = 'https://' + candidate.replace(/^\/+/, '');
3738 + }
3739 +
3740 + parsed = candidate.match(/^(?:([a-z][a-z0-9+.-]*):)?\/\/([^/?#]*)([^?#]*)/i);
3741 + if (!parsed || (parsed[1] && parsed[1].toLowerCase() !== 'http' && parsed[1].toLowerCase() !== 'https')) {
3742 + return false;
3743 + }
3744 +
3745 + path = parsed[3] || '';
3746 + if (path.replace(/^\/+|\/+$/g, '') !== '') {
3747 + return false;
3748 + }
3749 +
3750 + authority = parsed[2].replace(/^.*@/, '');
3751 + if (authority.charAt(0) === '[') {
3752 + return /^\[[0-9a-f:.]+\](?::\d+)?$/i.test(authority);
3753 + }
3754 +
3755 + host = authority.replace(/:\d+$/, '');
3756 + if (!/^[\p{L}\p{N}._-]+$/u.test(host) || host.indexOf('..') !== -1) {
3757 + return false;
3758 + }
3759 +
3760 + return !/^[.-]|[.-]$/.test(host) && /[\p{L}\p{N}]/u.test(host);
3761 + }
3762 +
3763 + function showMessage(lines, type) {
3764 + var $list = $('<div/>');
3765 + $.each(lines, function (i, line) {
3766 + $list.append($('<p/>').css('margin', i === 0 ? '0' : '8px 0 0 0').text(line));
3767 + });
3768 +
3769 + $message
3770 + .css({
3771 + 'background': type === 'success' ? 'rgba(34, 197, 94, 0.1)' : 'rgba(239, 68, 68, 0.1)',
3772 + 'border': '1px solid ' + (type === 'success' ? 'rgba(34, 197, 94, 0.3)' : 'rgba(239, 68, 68, 0.3)'),
3773 + 'color': type === 'success' ? '#22c55e' : '#ef4444',
3774 + 'padding': '12px 16px',
3775 + 'border-radius': '6px',
3776 + 'font-size': '14px',
3777 + 'line-height': '1.5'
3778 + })
3779 + .empty()
3780 + .append($list)
3781 + .show();
3782 + }
3783 +
3784 + // Repaint from what was actually stored, never from what was typed, so a
3785 + // refused value reads as refused rather than looking saved.
3786 + function applyStoredSettings(settings) {
3787 + if (!settings) {
3788 + return;
3789 + }
3790 + headlessModeSavedSettings = $.extend({}, settings);
3791 + headlessModeWasActive = !!settings.enabled && isUsableFrontendDomain(settings.frontend_domain);
3792 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>').prop('checked', !!settings.enabled);
3793 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>').val(settings.frontend_domain);
3794 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'slash_policy'); ?>').val(settings.slash_policy);
3795 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'path_prefixes'); ?>').val(settings.path_prefixes);
3796 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'refresh_interval_minutes'); ?>').val(settings.refresh_interval_minutes);
3797 + }
3798 +
3799 + $saveBtn.on('click', function (e) {
3800 + e.preventDefault();
3801 +
3802 + var data = {
3803 + action: 'metasync_save_headless_settings',
3804 + <?php echo esc_js(self::HEADLESS_NONCE_FIELD); ?>: $('#<?php echo esc_js(self::HEADLESS_NONCE_FIELD); ?>').val(),
3805 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>').is(':checked') ? '1' : '0',
3806 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>').val(),
3807 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'slash_policy'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'slash_policy'); ?>').val(),
3808 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'path_prefixes'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'path_prefixes'); ?>').val(),
3809 + <?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'refresh_interval_minutes'); ?>: $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'refresh_interval_minutes'); ?>').val()
3810 + };
3811 +
3812 + var submittedModeIsEnabled = data['<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'enabled'); ?>'] === '1';
3813 + var submittedDomain = data['<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>'];
3814 + var submittedModeIsActive = submittedModeIsEnabled
3815 + && isUsableFrontendDomain(submittedDomain);
3816 +
3817 + if (submittedModeIsEnabled && !submittedModeIsActive) {
3818 + showMessage(['Enter a valid public frontend URL before enabling Headless Mode. Use a scheme and host only, for example https://www.example.com.'], 'error');
3819 + $('#<?php echo esc_js(self::HEADLESS_FIELD_PREFIX . 'frontend_domain'); ?>').trigger('focus');
3820 + return;
3821 + }
3822 +
3823 + var confirmationMessage = submittedModeIsEnabled ? headlessDomainWarning : headlessDisableWarning;
3824 +
3825 + if (headlessModeWasActive && !submittedModeIsActive && !window.confirm(confirmationMessage)) {
3826 + applyStoredSettings(headlessModeSavedSettings);
3827 + return;
3828 + }
3829 +
3830 + $saveBtn.prop('disabled', true);
3831 + $saveBtn.find('.save-text').text('Saving...');
3832 + $message.hide();
3833 +
3834 + $.ajax({
3835 + url: window.ajaxurl,
3836 + type: 'POST',
3837 + data: data,
3838 + success: function (response) {
3839 + if (response && response.success) {
3840 + applyStoredSettings(response.data.settings);
3841 + var lines = [response.data.message];
3842 + if (response.data.notes && response.data.notes.length) {
3843 + lines = lines.concat(response.data.notes);
3844 + }
3845 + showMessage(lines, response.data.notes && response.data.notes.length ? 'error' : 'success');
3846 + } else {
3847 + showMessage([(response && response.data && response.data.message) || 'Error saving Headless Mode settings.'], 'error');
3848 + }
3849 + $saveBtn.prop('disabled', false);
3850 + $saveBtn.find('.save-text').text('Save Headless Settings');
3851 + },
3852 + error: function () {
3853 + showMessage(['An error occurred while saving Headless Mode settings. Please try again.'], 'error');
3854 + $saveBtn.prop('disabled', false);
3855 + $saveBtn.find('.save-text').text('Save Headless Settings');
3856 + }
3857 + });
3858 + });
3859 + });
3860 + </script>
3861 + <?php
3862 + }
3863 +
3864 + /**
3865 + * Render the stored post-type => prefix map back into the textarea format.
3866 + *
3867 + * @param mixed $prefixes Stored prefix map.
3868 + * @return string One `post_type = prefix` entry per line.
3869 + */
3870 + public static function format_headless_path_prefixes($prefixes)
3871 + {
3872 + if (!is_array($prefixes)) {
3873 + return '';
3874 + }
3875 +
3876 + $lines = array();
3877 +
3878 + foreach ($prefixes as $post_type => $prefix) {
3879 + $lines[] = $post_type . ' = ' . $prefix;
3880 + }
3881 +
3882 + return implode("\n", $lines);
3883 + }
3884 +
3885 + /**
3886 + * Read the textarea format back into a post-type => prefix map.
3887 + *
3888 + * One entry per line, `post_type = prefix`. A plain text format is enough
3889 + * for a demo and needs no repeater UI. A line with no `=` names no post
3890 + * type, so it is skipped rather than guessed at; everything that does
3891 + * survive is still handed to Metasync_Headless_Config::normalize(), which
3892 + * is what actually decides whether a post type and prefix are usable.
3893 + *
3894 + * @param mixed $text Raw textarea contents.
3895 + * @return array<string,string> Unsanitised map, keyed by post type.
3896 + */
3897 + public static function parse_headless_path_prefixes($text)
3898 + {
3899 + $map = array();
3900 +
3901 + if (!is_string($text)) {
3902 + return $map;
3903 + }
3904 +
3905 + foreach (preg_split('/\R/', $text) as $line) {
3906 + if (strpos($line, '=') === false) {
3907 + continue;
3908 + }
3909 +
3910 + list($post_type, $prefix) = explode('=', $line, 2);
3911 +
3912 + $post_type = trim($post_type);
3913 + if ($post_type === '') {
3914 + continue;
3915 + }
3916 +
3917 + $map[$post_type] = trim($prefix);
3918 + }
3919 +
3920 + return $map;
3921 + }
3922 +
3923 + /**
3924 + * Merge a Headless Mode submission over the stored settings and persist it.
3925 + *
3926 + * Additive on purpose: a key the request did not carry keeps whatever is
3927 + * already stored. normalize() treats an absent `enabled` as false, so a
3928 + * submission that omitted the checkbox would otherwise switch the mode off
3929 + * as a side effect of saving something else. The hidden field in front of
3930 + * the checkbox is the other half of that guard — it makes "off" an explicit
3931 + * `0` rather than an absence, so unticking the box still works.
3932 + *
3933 + * Sanitising is left entirely to Metasync_Headless_Config.
3934 + *
3935 + * @param mixed $submitted Raw request data, normally $_POST.
3936 + * @return array The settings that were actually stored.
3937 + */
3938 + public function save_headless_settings($submitted)
3939 + {
3940 + if (!is_array($submitted)) {
3941 + $submitted = array();
3942 + }
3943 +
3944 + $prefix = self::HEADLESS_FIELD_PREFIX;
3945 + $input = Metasync_Headless_Config::get_settings();
3946 +
3947 + if (isset($submitted[$prefix . 'enabled'])) {
3948 + $input['enabled'] = $submitted[$prefix . 'enabled'];
3949 + }
3950 +
3951 + if (isset($submitted[$prefix . 'frontend_domain'])) {
3952 + $input['frontend_domain'] = wp_unslash($submitted[$prefix . 'frontend_domain']);
3953 + }
3954 +
3955 + if (isset($submitted[$prefix . 'slash_policy'])) {
3956 + $input['slash_policy'] = $submitted[$prefix . 'slash_policy'];
3957 + }
3958 +
3959 + if (isset($submitted[$prefix . 'path_prefixes'])) {
3960 + $input['path_prefixes'] = self::parse_headless_path_prefixes(
3961 + wp_unslash($submitted[$prefix . 'path_prefixes'])
3962 + );
3963 + }
3964 +
3965 + if (isset($submitted[$prefix . 'refresh_interval_minutes'])) {
3966 + $input['refresh_interval_minutes'] = $submitted[$prefix . 'refresh_interval_minutes'];
3967 + }
3968 +
3969 + return Metasync_Headless_Config::update_settings($input);
3970 + }
3971 +
3972 + /**
3973 + * AJAX handler for the Headless Mode block on the Advanced tab.
3974 + *
3975 + * Same nonce-then-capability shape as the other Advanced tab handlers.
3976 + *
3977 + * @return void
3978 + */
3979 + /**
3980 + * Did the last write actually land?
3981 + *
3982 + * update_settings() hands back what it meant to store, not proof that it
3983 + * reached the database: a full disk, a failing object cache or a read-only
3984 + * replica all produce a silent no-op. Telling an operator their headless
3985 + * settings saved when they did not is worse than telling them nothing —
3986 + * they would go on to wonder why the frontend sees none of it.
3987 + *
3988 + * @param array $intended What the save call reported storing.
3989 + * @return bool
3990 + */
3991 + private function headless_settings_persisted(array $intended)
3992 + {
3993 + Metasync_Headless_Config::flush();
3994 +
3995 + return Metasync_Headless_Config::get_settings() == $intended;
3996 + }
3997 +
3998 + public function ajax_save_headless_settings()
3999 + {
4000 + $prefix = self::HEADLESS_FIELD_PREFIX;
4001 +
4002 + $headless_nonce = isset($_POST[self::HEADLESS_NONCE_FIELD]) ? sanitize_text_field(wp_unslash($_POST[self::HEADLESS_NONCE_FIELD])) : '';
4003 + if (!wp_verify_nonce($headless_nonce, self::HEADLESS_NONCE_ACTION)) {
4004 + wp_send_json_error(array('message' => 'Invalid security token. Please refresh the page and try again.'));
4005 + } elseif (!Metasync::current_user_has_plugin_access()) {
4006 + wp_send_json_error(array('message' => 'Insufficient permissions to save settings.'));
4007 + } else {
4008 + // phpcs:disable WordPress.Security.ValidatedSanitizedInput -- sanitised via Metasync_Headless_Config::normalize_domain() inside save_headless_settings().
4009 + $submitted_domain = isset($_POST[$prefix . 'frontend_domain'])
4010 + ? trim((string) wp_unslash($_POST[$prefix . 'frontend_domain']))
4011 + : '';
4012 + // phpcs:enable WordPress.Security.ValidatedSanitizedInput
4013 +
4014 + $stored = $this->save_headless_settings($_POST);
4015 +
4016 + if (!$this->headless_settings_persisted($stored)) {
4017 + wp_send_json_error(array(
4018 + 'message' => 'Those settings could not be saved. Nothing has changed — please try again, and check the site error log if it keeps happening.',
4019 + ));
4020 + }
4021 +
4022 + # Say so when the sanitiser refused something, rather than leaving the
4023 + # operator to spot that a field came back empty.
4024 + $notes = array();
4025 +
4026 + if ($submitted_domain !== '' && $stored['frontend_domain'] === '') {
4027 + $notes[] = 'That frontend domain was not saved. Give a scheme and host only, with no path — for example https://www.example.com.';
4028 + }
4029 +
4030 + if (!empty($stored['enabled']) && $stored['frontend_domain'] === '') {
4031 + $notes[] = 'Headless Mode is switched on but has no usable frontend domain, so nothing behaves as headless yet.';
4032 + }
4033 +
4034 + wp_send_json_success(array(
4035 + 'message' => 'Headless Mode settings saved.',
4036 + 'notes' => $notes,
4037 + 'settings' => array(
4038 + 'enabled' => !empty($stored['enabled']),
4039 + 'frontend_domain' => $stored['frontend_domain'],
4040 + 'slash_policy' => $stored['slash_policy'],
4041 + 'path_prefixes' => self::format_headless_path_prefixes($stored['path_prefixes']),
4042 + 'refresh_interval_minutes' => $stored['refresh_interval_minutes'],
4043 + ),
4044 + ));
4045 + }
4046 + }
4047 +
4048 + /**
2959 4049 * AJAX handler for saving SEO / Indexation Control settings.
2960 4050 *
2961 4051 * Moved from Metasync_Admin – the admin class now delegates here.
2962 4052 */
@@ -2962,13 +4052,26 @@
2962 4052 */
2963 4053 public function meta_sync_save_seo_controls()
2964 4054 {
2965 4055 try {
2966 - if (!isset($_POST['meta_sync_seo_controls_nonce']) || !wp_verify_nonce($_POST['meta_sync_seo_controls_nonce'], 'meta_sync_seo_controls_nonce')) {
4056 + $seo_controls_nonce = isset($_POST['meta_sync_seo_controls_nonce']) ? sanitize_text_field(wp_unslash($_POST['meta_sync_seo_controls_nonce'])) : '';
4057 + if (!wp_verify_nonce($seo_controls_nonce, 'meta_sync_seo_controls_nonce')) {
2967 4058 wp_send_json_error(array('message' => 'Invalid nonce'));
2968 4059 return;
2969 4060 }
2970 4061
4062 + // Same nonce-then-capability shape as the other settings save
4063 + // handlers: the nonce is not authorization, so a logged-in user
4064 + // without plugin access must still be refused here.
4065 + if (!Metasync::current_user_has_plugin_access()) {
4066 + wp_send_json_error(array('message' => 'Insufficient permissions.'), 403);
4067 + // The return is unreachable in WordPress (wp_send_json_error
4068 + // terminates the request) but keeps the handler safe under the
4069 + // non-terminating stubs the unit tests run with.
4070 + // @phpstan-ignore-next-line deadCode.unreachable
4071 + return;
4072 + }
4073 +
2971 4074 $current_options = Metasync::get_option();
2972 4075
2973 4076 $original_options = json_decode(json_encode($current_options), true);
2974 4077
@@ -2984,9 +4087,10 @@
2984 4087 'index_format_archives',
2985 4088 'override_robots_tags',
2986 4089 'add_nofollow_to_external_links',
2987 4090 'enable_googleinstantindex',
2988 - 'enable_binginstantindex'
4091 + 'enable_binginstantindex',
4092 + 'noindex_empty_archives'
2989 4093 ];
2990 4094
2991 4095 foreach ($seo_control_fields as $field) {
2992 4096 if (isset($_POST['metasync_options']['seo_controls'][$field]) && $_POST['metasync_options']['seo_controls'][$field] === 'true') {
@@ -3033,10 +4137,9 @@
3033 4137 }
3034 4138 wp_send_json_error(array('message' => $error_message));
3035 4139 }
3036 4140 } catch (Exception $e) {
3037 - error_log('Indexation Control AJAX Exception: ' . $e->getMessage());
3038 - error_log('Indexation Control AJAX Stack trace: ' . $e->getTraceAsString());
4141 + error_log('Indexation Control AJAX Exception: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3039 4142 wp_send_json_error(array('message' => 'Error saving settings: ' . $e->getMessage()));
3040 4143 }
3041 4144 }
3042 4145
@@ -3048,12 +4151,12 @@
3048 4151 * @return bool True on success, false on failure
3049 4152 */
3050 4153 public function save_bing_inline_settings_ajax()
3051 4154 {
3052 - $api_key = isset($_POST['metasync_bing_api_key_inline']) ? sanitize_text_field(wp_unslash($_POST['metasync_bing_api_key_inline'])) : '';
3053 - $endpoint = isset($_POST['metasync_bing_endpoint_inline']) ? sanitize_text_field(wp_unslash($_POST['metasync_bing_endpoint_inline'])) : 'indexnow';
3054 - $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'])) : [];
3055 - $disable_other_plugins = isset($_POST['metasync_bing_disable_other_plugins_inline']) ? true : false;
4155 + $api_key = isset($_POST['metasync_bing_api_key_inline']) ? sanitize_text_field(wp_unslash($_POST['metasync_bing_api_key_inline'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller verifies the SEO controls nonce first
4156 + $endpoint = isset($_POST['metasync_bing_endpoint_inline']) ? sanitize_text_field(wp_unslash($_POST['metasync_bing_endpoint_inline'])) : 'indexnow'; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller verifies the SEO controls nonce first
4157 + $post_types = isset($_POST['metasync_bing_post_types_inline']) && is_array($_POST['metasync_bing_post_types_inline']) ? array_map('sanitize_title', wp_unslash($_POST['metasync_bing_post_types_inline'])) : []; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller verifies the SEO controls nonce first
4158 + $disable_other_plugins = isset($_POST['metasync_bing_disable_other_plugins_inline']) ? true : false; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- caller verifies the SEO controls nonce first
3056 4159
3057 4160 $existing_settings = get_option('metasync_options_bing_instant_indexing', []);
3058 4161
3059 4162 $new_settings = [
@@ -3077,16 +4180,24 @@
3077 4180 $safe_key = sanitize_file_name( $api_key );
3078 4181 $file_path = ABSPATH . $safe_key . '.txt';
3079 4182 $real_path = realpath( ABSPATH );
3080 4183 if ( false === $real_path || 0 !== strpos( realpath( dirname( $file_path ) ), $real_path ) ) {
3081 - error_log( 'Bing IndexNow: Refusing to write outside ABSPATH' );
4184 + error_log( 'Bing IndexNow: Refusing to write outside ABSPATH' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
3082 4185 return false;
3083 4186 }
3084 - $file_result = file_put_contents( $file_path, $safe_key );
4187 + // serve the key virtually via template_redirect instead of
4188 + // writing a physical {key}.txt. An on-disk file is served statically
4189 + // by the web server and 403'd before PHP on some nginx setups, and
4190 + // cannot be written on read-only roots. Register the key (the saved
4191 + // api_key is also always part of the served set) and remove any stale
4192 + // physical file so the PHP route governs.
4193 + if (!class_exists('Metasync_Bing_Instant_Index')) {
4194 + require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
4195 + }
4196 + Metasync_Bing_Instant_Index::register_virtual_key($safe_key);
3085 4197
3086 - if ($file_result === false) {
3087 - error_log('Bing IndexNow: Failed to create API key verification file at ' . $file_path);
3088 - return false;
4198 + if (file_exists($file_path)) {
4199 + @wp_delete_file($file_path);
3089 4200 }
3090 4201
3091 4202 $current_options = Metasync::get_option();
3092 4203 if (!isset($current_options['seo_controls']['enable_binginstantindex']) || $current_options['seo_controls']['enable_binginstantindex'] !== 'true') {