Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.79% covered (warning)
88.79%
103 / 116
56.25% covered (warning)
56.25%
9 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Initial_State
88.79% covered (warning)
88.79%
103 / 116
56.25% covered (warning)
56.25%
9 / 16
50.11
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 render
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_initial_state
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
3
 get_wp_api_root
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 get_reader_chat_guidelines_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_ai_agent_access_guidelines_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_guidelines_url
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 is_ai_agent_access_available
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 get_block_template_overlay_config
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 is_private_site
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 is_automattic_proxied_request
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
13
 current_user_data
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 get_post_types_with_labels
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 get_purchase_token
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
6.28
 generate_purchase_token
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 current_user_can_purchase
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
1<?php
2/**
3 * The React initial state.
4 *
5 * @package automattic/jetpack-search
6 */
7
8namespace Automattic\Jetpack\Search;
9
10use Automattic\Jetpack\Connection\Manager as Connection_Manager;
11use Automattic\Jetpack\Constants;
12use Automattic\Jetpack\Status;
13use Jetpack_Options;
14
15/**
16 * The React initial state.
17 */
18class Initial_State {
19
20    /**
21     * Connection Manager
22     *
23     * @var Connection_Manager
24     */
25    protected $connection_manager;
26
27    /**
28     * Search Module Control
29     *
30     * @var Module_Control
31     */
32    protected $module_control;
33
34    /**
35     * Constructor
36     *
37     * @param Connection_Manager $connection_manager - Connection mananger instance.
38     * @param Module_Control     $module_control - Module control instance.
39     */
40    public function __construct( $connection_manager = null, $module_control = null ) {
41        $this->connection_manager = $connection_manager ? $connection_manager : new Connection_Manager( Package::SLUG );
42        $this->module_control     = $module_control ? $module_control : new Module_Control();
43    }
44
45    /**
46     * Render JS for the initial state
47     *
48     * @return string - JS string.
49     */
50    public function render() {
51        return 'var JETPACK_SEARCH_DASHBOARD_INITIAL_STATE=' . wp_json_encode( $this->get_initial_state(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';';
52    }
53
54    /**
55     * Get the initial state data.
56     *
57     * @return array
58     */
59    public function get_initial_state() {
60        return array(
61            'siteData'        => array(
62                'WP_API_root'                => esc_url_raw( rest_url() ),
63                'wpcomOriginApiUrl'          => $this->get_wp_api_root(),
64                'WP_API_nonce'               => wp_create_nonce( 'wp_rest' ),
65                'registrationNonce'          => wp_create_nonce( 'jetpack-registration-nonce' ),
66                'purchaseToken'              => $this->get_purchase_token(),
67                /**
68                 * Whether promotions are visible or not.
69                 *
70                 * @param bool $are_promotions_active Status of promotions visibility. True by default.
71                 */
72                'showPromotions'             => apply_filters( 'jetpack_show_promotions', true ),
73                'adminUrl'                   => esc_url( admin_url() ),
74                'readerChatGuidelinesUrl'    => $this->get_reader_chat_guidelines_url(),
75                'aiAgentAccessAvailable'     => $this->is_ai_agent_access_available(),
76                'aiAgentAccessGuidelinesUrl' => $this->get_ai_agent_access_guidelines_url(),
77                'blogId'                     => Jetpack_Options::get_option( 'id', 0 ),
78                'version'                    => Package::VERSION,
79                'calypsoSlug'                => ( new Status() )->get_site_suffix(),
80                'title'                      => get_bloginfo( 'name' ),
81                'postTypes'                  => $this->get_post_types_with_labels(),
82                'isWpcom'                    => Helper::is_wpcom(),
83                // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
84                'isPlanJustUpgraded'         => isset( $_GET['just_upgraded'] ) && wp_unslash( $_GET['just_upgraded'] ),
85                /**
86                 * Whether the Jetpack Search 3.0 Interactivity API blocks are enabled.
87                 * Mirrors the `jetpack_search_blocks_enabled` server-side filter so the
88                 * dashboard React app can gate the new feature-selection UI on the
89                 * same flag the back end uses to register the blocks themselves.
90                 */
91                'searchBlocksEnabled'        => (bool) apply_filters( 'jetpack_search_blocks_enabled', false ),
92                /**
93                 * Whether the experimental blocks-powered Overlay search experience
94                 * is available in the Experience Selector. Mirrors the
95                 * `jetpack_search_overlay_block_template_enabled` server-side filter
96                 * so the dashboard React app can gate the new card on the same flag
97                 * the back end uses to enable the runtime swap. Sites that haven't
98                 * opted into the experimental overlay continue to see the four
99                 * original cards unchanged.
100                 */
101                'blockOverlayEnabled'        => (bool) apply_filters( 'jetpack_search_overlay_block_template_enabled', false ),
102                /**
103                 * Editor affordances for the experimental blocks-powered overlay.
104                 * Surfaces in the new Overlay search card so admins can edit the
105                 * rendered template via the standard block editor on `post.php` â€”
106                 * works on both block and classic themes. URLs are null for any
107                 * visitor without `manage_options`; the action handlers also
108                 * enforce that capability server-side.
109                 */
110                'blockTemplateOverlay'       => $this->get_block_template_overlay_config(),
111                // Gates the WooCommerce Product Search control to stores.
112                'isWooCommerceActive'        => Search_Blocks::woocommerce_blocks_enabled(),
113                /**
114                 * Active theme stylesheet â€” used by the experience-selector to deep-link
115                 * the "Edit search template" action to the right Site Editor entry
116                 * (`?p=/wp_template/<stylesheet>//jetpack-search`).
117                 */
118                'activeThemeStylesheet'      => get_stylesheet(),
119                /**
120                 * Whether the active theme is a block theme. The Embedded search
121                 * experience is built and customized in the Site Editor, which
122                 * classic themes don't have, so the dashboard blocks switching to
123                 * Embedded when this is false.
124                 */
125                'themeSupportsBlocks'        => wp_is_block_theme(),
126            ),
127            'userData'        => array(
128                'currentUser' => $this->current_user_data(),
129            ),
130            'jetpackSettings' => array(
131                'search'                 => $this->module_control->is_active(),
132                'instant_search_enabled' => $this->module_control->is_instant_search_enabled(),
133                'experience'             => $this->module_control->get_experience(),
134            ),
135            'features'        => array_map(
136                'sanitize_text_field',
137                // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
138                isset( $_GET['features'] ) ? explode( ',', wp_unslash( $_GET['features'] ) ) : array()
139            ),
140        );
141    }
142
143    /**
144     * Get API root.
145     *
146     * It return first party API root for WPCOM simple sites.
147     */
148    protected function get_wp_api_root() {
149        if ( ! Helper::is_wpcom() ) {
150            return esc_url_raw( rest_url() );
151        }
152        // First party API prefix for WPCOM.
153        return esc_url_raw( site_url( '/wp-json/wpcom-origin/' ) );
154    }
155
156    /**
157     * Get the Reader Chat guidelines admin page URL when it is registered.
158     *
159     * The guidelines page is controlled outside Jetpack. Returning an empty
160     * URL lets the dashboard hide the link when that experiment is unavailable.
161     *
162     * @return string Guidelines admin URL, or an empty string when unavailable.
163     */
164    protected function get_reader_chat_guidelines_url() {
165        return $this->get_guidelines_url();
166    }
167
168    /**
169     * Get the AI Agent Access guidelines admin page URL when it is registered.
170     *
171     * The guidelines page is controlled outside Jetpack. Returning an empty
172     * URL lets the dashboard hide the link when that page is unavailable.
173     *
174     * @return string Guidelines admin URL, or an empty string when unavailable.
175     */
176    protected function get_ai_agent_access_guidelines_url() {
177        return $this->get_guidelines_url();
178    }
179
180    /**
181     * Get the Guidelines admin page URL when it is registered.
182     *
183     * @return string Guidelines admin URL, or an empty string when unavailable.
184     */
185    protected function get_guidelines_url() {
186        if ( ! function_exists( 'menu_page_url' ) ) {
187            return '';
188        }
189
190        return esc_url_raw( menu_page_url( 'guidelines-wp-admin', false ) );
191    }
192
193    /**
194     * Check whether the AI Agent Access toggle should be available.
195     *
196     * The feature is rollout-gated to proxied Automattic contexts so regular
197     * site owners, and non-proxied staff, do not see unfinished controls.
198     *
199     * IMPORTANT: Only use for feature gating, not for authorization.
200     *
201     * @return bool
202     */
203    protected function is_ai_agent_access_available() {
204        return $this->is_automattic_proxied_request() && ! $this->is_private_site();
205    }
206
207    /**
208     * Build the block-template overlay editor config exposed to the dashboard.
209     *
210     * The action handlers gate on `current_user_can( 'manage_options' )`
211     * server-side, so non-admins can't actually drive the flow. We also
212     * skip emitting the nonce'd URLs (and the singleton-id lookup that
213     * decides `isCustomized`) to those users â€” useless to them and
214     * needlessly leaks internal URL shape.
215     *
216     * `enabled` mirrors `Search_Blocks::is_block_template_overlay_enabled()`,
217     * which itself requires both the operator-level filter AND the site
218     * owner having activated the `overlay_blocks` experience â€” so the
219     * editor surface only appears when the new overlay is actually live.
220     *
221     * @return array{enabled: bool, editorUrl: string|null, resetRestPath: string|null, isCustomized: bool}
222     */
223    protected function get_block_template_overlay_config(): array {
224        $enabled  = Search_Blocks::is_block_template_overlay_enabled();
225        $can_edit = $enabled && current_user_can( 'manage_options' );
226        return array(
227            'enabled'       => $enabled,
228            'editorUrl'     => $can_edit ? Overlay_Template::get_editor_url() : null,
229            // `resetRestPath` is the path the dashboard sends to `apiFetch`
230            // as a DELETE â€” hits the CPT's built-in REST endpoint
231            // (`/wp/v2/jetpack-search-overlay/<id>?force=true`) so the
232            // reset happens via AJAX with no page navigation. Null when
233            // there's nothing to reset; the link is also gated on
234            // `isCustomized`.
235            'resetRestPath' => $can_edit ? Overlay_Template::get_reset_rest_path() : null,
236            // `isCustomized` lets the React dashboard hide "Restore default"
237            // when there's nothing to restore â€” checks both that the
238            // singleton exists AND that it isn't in the trash (admins can
239            // trash via post.php directly; in that state the front end
240            // already falls back to the bundled template).
241            'isCustomized'  => $can_edit && Overlay_Template::is_customized(),
242        );
243    }
244
245    /**
246     * Check whether the current site is private.
247     *
248     * @return bool
249     */
250    protected function is_private_site() {
251        if ( function_exists( 'is_private_blog' ) ) {
252            return (bool) \is_private_blog();
253        }
254
255        return -1 === (int) get_option( 'blog_public', 1 );
256    }
257
258    /**
259     * Check whether the current request is coming from a proxied Automattic context.
260     *
261     * Keep this check local to the rollout gate so WPCOM environments with older
262     * vendored Jetpack packages do not fatal during bootstrap.
263     *
264     * @return bool
265     */
266    protected function is_automattic_proxied_request() {
267        if ( function_exists( 'wpcom_is_proxied_request' ) && \wpcom_is_proxied_request() ) {
268            return true;
269        }
270
271        if (
272            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- boolean check only.
273            ( isset( $_SERVER['A8C_PROXIED_REQUEST'] ) && (bool) sanitize_text_field( wp_unslash( $_SERVER['A8C_PROXIED_REQUEST'] ) ) ) ||
274            Constants::is_true( 'A8C_PROXIED_REQUEST' )
275        ) {
276            return true;
277        }
278
279        if ( Constants::is_true( 'AT_PROXIED_REQUEST' ) && Constants::is_defined( 'ATOMIC_CLIENT_ID' ) ) {
280            switch ( (int) Constants::get_constant( 'ATOMIC_CLIENT_ID' ) ) {
281                case 1:
282                case 2:
283                case 3: // Pressable.
284                case 32:
285                case 118: // Commerce garden client (ciab).
286                    return true;
287            }
288        }
289
290        return false;
291    }
292
293    /**
294     * Gather data about the current user.
295     *
296     * @return array
297     */
298    protected function current_user_data() {
299        $current_user      = wp_get_current_user();
300        $is_user_connected = $this->connection_manager->is_user_connected( $current_user->ID );
301        $is_master_user    = $is_user_connected && (int) $current_user->ID && (int) Jetpack_Options::get_option( 'master_user' ) === (int) $current_user->ID;
302        $dotcom_data       = $this->connection_manager->get_connected_user_data();
303
304        $current_user_data = array(
305            'isConnected' => $is_user_connected,
306            'isMaster'    => $is_master_user,
307            'username'    => $current_user->user_login,
308            'id'          => $current_user->ID,
309            'wpcomUser'   => $dotcom_data,
310            'permissions' => array(
311                'manage_options' => current_user_can( 'manage_options' ),
312            ),
313        );
314
315        return $current_user_data;
316    }
317
318    /**
319     * Gets the post type labels for all of the site's post types (including custom post types)
320     *
321     * @return array
322     */
323    protected function get_post_types_with_labels() {
324
325        $args = array(
326            'public' => true,
327        );
328
329        $post_types_with_labels = array();
330
331        $post_types = get_post_types( $args, 'objects' );
332
333        // We don't need all the additional post_type data, just the slug & label
334        foreach ( $post_types as $post_type ) {
335            $post_type_with_label = array(
336                'slug'  => $post_type->name,
337                'label' => $post_type->label,
338            );
339
340            $post_types_with_labels[ $post_type->name ] = $post_type_with_label;
341        }
342        return $post_types_with_labels;
343    }
344
345    /**
346     * Gets a purchase token that is used for Jetpack logged out visitor checkout.
347     * The purchase token should be appended to all CTA url's that lead to checkout.
348     *
349     * @return string|boolean
350     */
351    protected function get_purchase_token() {
352        if ( ! $this->current_user_can_purchase() ) {
353            return false;
354        }
355
356        $purchase_token = Jetpack_Options::get_option( 'purchase_token', false );
357
358        if ( $purchase_token ) {
359            return $purchase_token;
360        }
361        // If the purchase token is not saved in the options table yet, then add it.
362        Jetpack_Options::update_option( 'purchase_token', $this->generate_purchase_token(), true );
363        return Jetpack_Options::get_option( 'purchase_token', false );
364    }
365
366    /**
367     * Generates a purchase token that is used for Jetpack logged out visitor checkout.
368     *
369     * @return string
370     */
371    protected function generate_purchase_token() {
372        return wp_generate_password( 12, false );
373    }
374
375    /**
376     * Determine if the current user is allowed to make Jetpack purchases without
377     * a WordPress.com account
378     *
379     * @return boolean True if the user can make purchases, false if not
380     */
381    public function current_user_can_purchase() {
382        // The site must be site-connected to Jetpack (no users connected).
383        if ( ! $this->connection_manager->is_site_connection() ) {
384            return false;
385        }
386
387        // Make sure only administrators can make purchases.
388        if ( ! current_user_can( 'manage_options' ) ) {
389            return false;
390        }
391
392        return true;
393    }
394}