PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | includes/class-metasync-settings-registration.php +79 -435 2.7.2 → 2.7.1 View file →
@@ -263,14 +263,8 @@
263 263 Metasync_Admin::option_key,
264 264 array(self::instance(), 'sanitize')
265 265 );
266 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 -
273 267 // LLMs.txt settings are stored in a dedicated option so MCP tools and
274 268 // the admin UI share one source of truth.
275 269 register_setting(
276 270 Metasync_Admin::option_group,
@@ -695,30 +689,14 @@
695 689 * Note: OTTO SSR is always enabled by default when connected
696 690 */
697 691
698 692 # 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.
704 693 add_settings_field(
705 694 'otto_pixel_uuid',
706 695 $whitelabel_otto_name . ' 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 - );
696 + function() use ($option_key) {
697 + $value = Metasync::get_option('general')['otto_pixel_uuid'] ?? '';
698 + echo '<input type="text" size="40" value = "'.esc_attr($value).'" name="' . esc_attr($option_key) . '[general][otto_pixel_uuid]"/>';
721 699 },
722 700 $page_slug . '_general',
723 701 $SECTION_METASYNC
724 702 );
@@ -764,13 +742,12 @@
764 742 # OTTO deployment visible on a page that also holds a custom value.
765 743 #
766 744 # This only re-orders what RENDERS. Nothing is written to or deleted
767 745 # 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.
746 + # untouched. Applies to the SEO title and meta description only.
770 747 add_settings_field(
771 748 'seo_priority',
772 - 'SEO Meta Priority',
749 + 'SEO Title & Description Priority',
773 750 function() use ($whitelabel_otto_name, $option_key) {
774 751 $seo_priority = Metasync::get_option('general')['seo_priority'] ?? '';
775 752 $checked = $seo_priority === Metasync_Seo_Precedence::PRIORITY_OTTO ? 'checked' : '';
776 753 $name = $option_key . '[general][seo_priority]';
@@ -782,9 +759,9 @@
782 759 esc_attr($checked),
783 760 esc_html($whitelabel_otto_name)
784 761 );
785 762 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>',
763 + '<p class="description" id="seo_priority_description"><strong>Off:</strong> Your custom title and description appear first; %1$s fills in only where you have no value.<br><strong>On:</strong> Approved %1$s values appear first; your custom values remain the fallback.<br><span class="metasync-seo-priority-note">Your saved values are never deleted or overwritten.</span></p>',
787 764 esc_html($whitelabel_otto_name)
788 765 );
789 766 echo '</div>';
790 767 },
@@ -791,87 +768,71 @@
791 768 $page_slug . '_general',
792 769 $SECTION_METASYNC
793 770 );
794 771
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';
772 + # WP Rocket compatibility mode setting
773 + add_settings_field(
774 + 'otto_wp_rocket_compat',
775 + 'WP Rocket Compatibility',
776 + function() use ($whitelabel_otto_name, $option_key) {
777 + $value = Metasync::get_option('general')['otto_wp_rocket_compat'] ?? 'auto';
778 + $wp_rocket_active = class_exists('WP_Rocket');
804 779
805 - add_settings_section(
806 - 'metasync_compat_toggles',
807 - '',
808 - function() {},
809 - $compat_page_slug
810 - );
780 + echo '<select id="otto_wp_rocket_compat" name="' . esc_attr($option_key) . '[general][otto_wp_rocket_compat]">';
781 + echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
782 + echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>';
783 + echo '<option value="http"' . selected($value, 'http', false) . '>HTTP Mode (Safer)</option>';
784 + echo '<option value="disable_otto"' . selected($value, 'disable_otto', false) . '>Disable ' . esc_html($whitelabel_otto_name) . ' when WP Rocket is active</option>';
785 + echo '</select>';
811 786
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 -
787 + if ($wp_rocket_active) {
830 788 echo '<p class="description" style="color: #0073aa; margin-top: 8px;">✓ WP Rocket detected - compatibility mode active</p>';
789 + }
831 790
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 - }
791 + echo '<p class="description" style="margin-top: 8px;">';
792 + 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>';
793 + echo '<strong>Buffer Mode:</strong> Forces output buffering method. Fastest but may conflict with some configurations.<br>';
794 + echo '<strong>HTTP Mode:</strong> Forces internal HTTP fetch method. Slower but more compatible.<br>';
795 + 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.';
796 + echo '</p>';
797 + },
798 + $page_slug . '_general',
799 + $SECTION_METASYNC
800 + );
843 801
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';
802 + # SiteGround Optimizer compatibility mode setting
803 + add_settings_field(
804 + 'otto_sg_optimizer_compat',
805 + 'SiteGround Optimizer Compatibility',
806 + function() use ($option_key) {
807 + $value = Metasync::get_option('general')['otto_sg_optimizer_compat'] ?? 'auto';
808 + $sg_optimizer_active = defined('SiteGround_Optimizer\VERSION') || class_exists('SiteGround_Optimizer\Parser\Parser');
852 809
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>';
810 + echo '<select id="otto_sg_optimizer_compat" name="' . esc_attr($option_key) . '[general][otto_sg_optimizer_compat]">';
811 + echo '<option value="auto"' . selected($value, 'auto', false) . '>Auto (Recommended)</option>';
812 + echo '<option value="buffer"' . selected($value, 'buffer', false) . '>Buffer Mode (Faster)</option>';
813 + echo '<option value="http"' . selected($value, 'http', false) . '>HTTP Mode (Safer)</option>';
814 + echo '</select>';
857 815
816 + if ($sg_optimizer_active) {
858 817 echo '<p class="description" style="color: #0073aa; margin-top: 8px;">✓ SiteGround Optimizer detected - compatibility mode active</p>';
818 + }
859 819
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 - }
820 + echo '<p class="description" style="margin-top: 8px;">';
821 + echo '<strong>Auto (Recommended):</strong> Uses the internal HTTP fetch method with SG Optimizer, protecting themes that defer inline CSS to the footer (e.g. Divi).<br>';
822 + echo '<strong>Buffer Mode (Faster):</strong> Renders in-process with no internal page fetch, roughly halving uncached render time. Best when pages can never be host-cached anyway (e.g. a plugin starts a PHP session on every request — though MetaSync already removes that automatically for BookingPress). May conflict with Divi-style themes.<br>';
823 + echo '<strong>HTTP Mode:</strong> Forces the internal HTTP fetch method. Slower but more compatible.';
824 + echo '</p>';
825 + },
826 + $page_slug . '_general',
827 + $SECTION_METASYNC
828 + );
869 829
870 - # BookingPress front-end session compatibility toggle.
830 + # BookingPress front-end session compatibility setting.
871 831 # Only registered when BookingPress is actually present on the site:
872 832 # the shim no-ops without it, so the field would be noise for every
873 - # other install.
833 + # other install. The stored value survives while the field is hidden
834 + # (the save loop is isset-guarded and keeps absent keys).
874 835 if (class_exists('bookingpress_spam_protection')) {
875 836 add_settings_field(
876 837 'bookingpress_session_compat',
877 838 'BookingPress Session Compatibility',
@@ -890,10 +851,10 @@
890 851 echo '<strong>Auto (Recommended):</strong> Removes the unnecessary session start on public pages so page caching can work.<br>';
891 852 echo '<strong>Disabled:</strong> Leaves BookingPress exactly as installed (no page caching while it stays active).';
892 853 echo '</p>';
893 854 },
894 - $compat_page_slug,
895 - 'metasync_compat_toggles'
855 + $page_slug . '_general',
856 + $SECTION_METASYNC
896 857 );
897 858 }
898 859
899 860 /**
@@ -2071,71 +2032,8 @@
2071 2032 return $base;
2072 2033 }
2073 2034
2074 2035 /**
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 2036 * Whether the array is a numerically-indexed list (keys 0..n-1).
2139 2037 *
2140 2038 * Answers false for the empty array — range(0, -1) counts downwards and
2141 2039 * yields [0, -1], not []. That is load-bearing rather than incidental: an
@@ -2246,9 +2144,8 @@
2246 2144 }
2247 2145 }
2248 2146 }
2249 2147 $general_tab_submitted = ($active_tab === 'general');
2250 - $compatibility_tab_submitted = ($active_tab === 'compatibility');
2251 2148
2252 2149 // General Settings
2253 2150 if (isset($input['general']['apikey'])) {
2254 2151 $new_input['general']['apikey'] = sanitize_text_field($input['general']['apikey']);
@@ -2253,26 +2150,8 @@
2253 2150 if (isset($input['general']['apikey'])) {
2254 2151 $new_input['general']['apikey'] = sanitize_text_field($input['general']['apikey']);
2255 2152 }
2256 2153
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 -
2275 2154 // Meta Box Visibility Settings
2276 2155 if (isset($input['general']) && $general_tab_submitted) {
2277 2156 $checkbox_fields = [
2278 2157 'disable_common_robots_metabox',
@@ -2773,16 +2652,21 @@
2773 2652 }
2774 2653 }
2775 2654 }
2776 2655
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 - }
2656 + // Indexation Control Settings
2657 + if (isset($input['seo_controls']['index_date_archives'])) {
2658 + $new_input['seo_controls']['index_date_archives'] = boolval($input['seo_controls']['index_date_archives']);
2784 2659 }
2660 + if (isset($input['seo_controls']['index_tag_archives'])) {
2661 + $new_input['seo_controls']['index_tag_archives'] = boolval($input['seo_controls']['index_tag_archives']);
2662 + }
2663 + if (isset($input['seo_controls']['index_author_archives'])) {
2664 + $new_input['seo_controls']['index_author_archives'] = boolval($input['seo_controls']['index_author_archives']);
2665 + }
2666 + if (isset($input['seo_controls']['index_format_archives'])) {
2667 + $new_input['seo_controls']['index_format_archives'] = boolval($input['seo_controls']['index_format_archives']);
2668 + }
2785 2669
2786 2670 // Handle post-save heartbeat trigger for domain changes
2787 2671 if (isset($new_input['_trigger_heartbeat_after_save'])) {
2788 2672 $context = $new_input['_trigger_heartbeat_after_save'];
@@ -2818,71 +2702,13 @@
2818 2702 if (isset($input['whitelabel'], $new_input['whitelabel'])) {
2819 2703 $input['whitelabel'] = $new_input['whitelabel'];
2820 2704 }
2821 2705
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 2706 // Layered merge: stored option, then the raw submission, then the
2870 2707 // sanitized values. Replaces the former shallow array_merge(), which
2871 2708 // let the raw submission overwrite every sanitized section wholesale.
2872 2709 $result = self::merge_option_layers($stored_option, $input, $new_input);
2873 2710
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 2711 if ($whitelabel_reset) {
2886 2712 $result['whitelabel'] = $new_input['whitelabel'];
2887 2713 }
2888 2714
@@ -2941,12 +2767,9 @@
2941 2767 'white_label_plugin_name', 'white_label_plugin_description',
2942 2768 'white_label_plugin_author', 'white_label_plugin_menu_slug',
2943 2769 'white_label_plugin_menu_icon', 'enabled_plugin_css',
2944 2770 'enabled_elementor_plugin_css_color','enabled_elementor_plugin_css',
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',
2771 + 'otto_pixel_uuid','periodic_clear_otto_cache','periodic_clear_ottopage_cache',
2949 2772 'periodic_clear_ottopost_cache', 'whitelabel_otto_name', 'otto_wp_rocket_compat',
2950 2773 'otto_sg_optimizer_compat',
2951 2774 'bookingpress_session_compat'
2952 2775 ];
@@ -2976,21 +2799,8 @@
2976 2799 // Whitelabel field validation issues are collected per-field and reported back
2977 2800 // without blocking the rest of the settings save.
2978 2801 $whitelabel_errors = [];
2979 2802
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 -
2993 2803 foreach ($text_fields as $field) {
2994 2804
2995 2805 if (isset($_POST['metasync_options']['general'][$field])) {
2996 2806
@@ -3004,9 +2814,10 @@
3004 2814 'white_label_plugin_description',
3005 2815 'white_label_plugin_author',
3006 2816 'white_label_plugin_menu_slug',
3007 2817 'white_label_plugin_menu_icon',
3008 - 'whitelabel_otto_name'
2818 + 'whitelabel_otto_name',
2819 + 'otto_pixel_uuid'
3009 2820 ];
3010 2821
3011 2822 if ($value === '' && !in_array($field, $clearable_fields)) {
3012 2823 continue;
@@ -3021,25 +2832,8 @@
3021 2832 continue;
3022 2833 }
3023 2834 }
3024 2835
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 -
3042 2836 if ($field === 'white_label_plugin_menu_icon') {
3043 2837
3044 2838 if ($value === '') {
3045 2839 $metasync_options['general'][$field] = '';
@@ -3128,34 +2922,16 @@
3128 2922 $parsed_url = wp_parse_url($value);
3129 2923 $host = isset($parsed_url['host']) ? $parsed_url['host'] : '';
3130 2924
3131 2925 if (strpos($host, '.') !== false || filter_var($host, FILTER_VALIDATE_IP)) {
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 - }
2926 + $metasync_options['general'][$field] = esc_url_raw($value);
3147 2927 } else {
3148 2928 $field_name = ($field === 'white_label_plugin_author_uri') ? 'Author URL' : 'Plugin URL';
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).';
2929 + $validation_errors[] = 'Invalid ' . $field_name . ' format. Please use a proper domain name (e.g., example.com).';
3154 2930 }
3155 2931 } else {
3156 2932 $field_name = ($field === 'white_label_plugin_author_uri') ? 'Author URL' : 'Plugin URL';
3157 - $branding_errors[$field] = $field_name . ' must be a valid http:// or https:// address.';
2933 + $validation_errors[] = 'Invalid ' . $field_name . ' format.';
3158 2934 }
3159 2935 }
3160 2936 }
3161 2937 }
@@ -3273,67 +3049,17 @@
3273 3049 $validation_errors[] = 'Add at least one of: Plugin Name, Logo URL, or Author to save whitelabel settings.';
3274 3050 }
3275 3051 }
3276 3052
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 -
3284 3053 if (!empty($validation_errors)) {
3285 3054 $this->force_release_settings_recovery_lock();
3286 3055 wp_send_json_error([
3287 - 'errors' => $validation_errors,
3288 - 'field_errors' => $branding_errors,
3056 + 'errors' => $validation_errors
3289 3057 ]);
3290 3058 return;
3291 3059 }
3292 3060
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 -
3302 3061 $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 -
3336 3062 // Stored value is encrypted at rest (or legacy plaintext/empty). Keep it as-is
3337 3063 // for preserve/revert, and decrypt a separate copy for plaintext comparison.
3338 3064 $old_stored_api_key = $old_options['searchatlas_api_key'] ?? '';
3339 3065 $old_api_key = Metasync::get_searchatlas_api_key();
@@ -3377,19 +3103,14 @@
3377 3103
3378 3104 if ( $ping['connected'] === true ) {
3379 3105 $api_key_validated = true;
3380 3106 $is_connected = true;
3381 - // Trusted ping flow: only a real UUID is stored. The
3382 - // form itself can no longer submit this field, so this ping is
3383 - // one of the few remaining sanctioned write paths for it.
3384 - if ( ! empty( $ping['otto_pixel_uuid'] ) && Metasync::is_valid_uuid( $ping['otto_pixel_uuid'] ) ) {
3107 + if ( ! empty( $ping['otto_pixel_uuid'] ) ) {
3385 3108 $existing_uuid = $metasync_options['general']['otto_pixel_uuid'] ?? '';
3386 3109 if ( empty( $existing_uuid ) || $existing_uuid !== $ping['otto_pixel_uuid'] ) {
3387 3110 $metasync_options['general']['otto_pixel_uuid'] = sanitize_text_field( $ping['otto_pixel_uuid'] );
3388 3111 Metasync::set_option($metasync_options);
3389 3112 }
3390 - } elseif ( ! empty( $ping['otto_pixel_uuid'] ) ) {
3391 - error_log('MetaSync: ignoring invalid otto_pixel_uuid from connection ping (not a UUID)');
3392 3113 }
3393 3114 } else {
3394 3115 // POST succeeded but ping didn't confirm — keep the key, warn user
3395 3116 $api_key_validated = true;
@@ -3441,15 +3162,8 @@
3441 3162 $api_key_changed = $old_api_key !== $new_api_key;
3442 3163 $api_key_added = empty($old_api_key) && !empty($new_api_key);
3443 3164 $api_key_removed = !empty($old_api_key) && empty($new_api_key);
3444 3165
3445 - if ( $api_key_removed ) {
3446 - Metasync_PostHog::feature('searchatlas_disconnected', array(
3447 - 'method' => 'key_removed',
3448 - 'was_connected' => true,
3449 - ));
3450 - }
3451 -
3452 3166 if ( $api_key_validated === true ) {
3453 3167 # Use current_time('mysql') so the value is consistent with the
3454 3168 # cron heartbeat writer and renders correctly via time_elapsed_string.
3455 3169 $send_auth_token_timestamp = Metasync::get_option();
@@ -3643,37 +3357,8 @@
3643 3357 update_option('metasync_llms_txt_settings', $this->sanitize_llms_txt_settings($llms_input));
3644 3358 }
3645 3359 }
3646 3360
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 -
3676 3361 $response_payload = array(
3677 3362 'message' => 'Settings saved successfully!',
3678 3363 'redirect_url' => $redirect_url,
3679 3364 );
@@ -4360,35 +4045,8 @@
4360 4045 }
4361 4046 }
4362 4047
4363 4048 /**
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 - /**
4391 4049 * AJAX handler for saving SEO / Indexation Control settings.
4392 4050 *
4393 4051 * Moved from Metasync_Admin – the admin class now delegates here.
4394 4052 */
@@ -4463,22 +4121,8 @@
4463 4121 $result = true;
4464 4122 }
4465 4123
4466 4124 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 -
4481 4125 $success_message = 'Indexation Control settings saved successfully!';
4482 4126 if (isset($_POST['metasync_bing_api_key_inline'])) {
4483 4127 $success_message = 'Indexation Control and Bing Instant Indexing settings saved successfully!';
4484 4128 }