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