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