Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.79% covered (success)
94.79%
91 / 96
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Survicate
95.79% covered (success)
95.79%
91 / 95
60.00% covered (warning)
60.00%
6 / 10
48
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_proxied
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 init
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 should_load
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
8
 is_big_sky_site
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 get_editor_context
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 get_wpcom_user_id
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 get_visitor_traits
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 get_asset_json
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
8.01
 enqueue_scripts
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * Survicate survey integration
4 *
5 * @package automattic/jetpack-mu-wpcom
6 */
7
8namespace A8C\FSE;
9
10use Automattic\Jetpack\Connection\Manager as Connection_Manager;
11use Automattic\Jetpack\Connection\Utils as Connection_Utils;
12use Automattic\Jetpack\Constants;
13
14/**
15 * Class Survicate
16 */
17class Survicate {
18    /**
19     * The shared Survicate bundle, built from wp-calypso's `apps/survicate` and served from widgets.wp.com.
20     */
21    const BUNDLE_URL = 'https://widgets.wp.com/survicate/survicate.min.js';
22
23    /**
24     * Path (without scheme) of the bundle's asset manifest: `dependencies` and `version`.
25     */
26    const ASSET_JSON_PATH = 'widgets.wp.com/survicate/survicate.asset.json';
27
28    /**
29     * Transient caching the decoded asset manifest.
30     */
31    const ASSET_TRANSIENT_KEY = 'wpcom_survicate_asset_json';
32
33    /**
34     * Value cached in the transient when the asset manifest could not be read.
35     */
36    const ASSET_UNAVAILABLE = 'unavailable';
37
38    /**
39     * Class instance.
40     *
41     * @var Survicate
42     */
43    private static $instance = null;
44
45    /**
46     * Survicate constructor.
47     */
48    public function __construct() {
49        add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ), 100 );
50    }
51
52    /**
53     * Returns whether the current request is coming from the a8c proxy.
54     */
55    private static function is_proxied() {
56        return isset( $_SERVER['A8C_PROXIED_REQUEST'] )
57            ? sanitize_text_field( wp_unslash( $_SERVER['A8C_PROXIED_REQUEST'] ) )
58            : defined( 'A8C_PROXIED_REQUEST' ) && A8C_PROXIED_REQUEST;
59    }
60
61    /**
62     * Creates instance.
63     *
64     * @return void
65     */
66    public static function init() {
67        if ( self::$instance === null ) {
68            self::$instance = new self();
69        }
70    }
71
72    /**
73     * Check whether Survicate should load on the current page.
74     *
75     * @return bool
76     */
77    private function should_load() {
78        /**
79         * Filters whether Survicate surveys load in wp-admin.
80         *
81         * A kill switch that WordPress.com or wpcomsh can flip without a Jetpack release.
82         *
83         * @since $$next-version$$
84         *
85         * @param bool $enabled Whether Survicate may load. Default true.
86         */
87        if ( ! apply_filters( 'wpcom_survicate_enabled', true ) ) {
88            return false;
89        }
90
91        if ( ! is_user_logged_in() ) {
92            return false;
93        }
94
95        if ( ! is_admin() ) {
96            return false;
97        }
98
99        // Network and user admin pages are internal tools surfaces; surveys must never reach them.
100        if ( is_network_admin() || is_user_admin() ) {
101            return false;
102        }
103
104        // Only load for English locale users.
105        if ( strpos( strtolower( get_user_locale() ), 'en' ) !== 0 ) {
106            return false;
107        }
108
109        // Atomic powers Automattic's internal P2s; surveys must never reach them.
110        if ( ( new \Automattic\Jetpack\Status\Host() )->is_p2_site() ) {
111            return false;
112        }
113
114        return true;
115    }
116
117    /**
118     * Detect whether the current site is a Big Sky site.
119     *
120     * Used as a visitor trait so Survicate's targeting UI can include or exclude
121     * Big Sky users without a code change.
122     *
123     * @return bool
124     */
125    private function is_big_sky_site() {
126        if ( ! function_exists( 'wpcom_has_blog_sticker' ) ) {
127            return false;
128        }
129
130        $blog_id = get_wpcom_blog_id();
131        if ( ! $blog_id ) {
132            return false;
133        }
134
135        return wpcom_has_blog_sticker( 'big-sky-enabled', $blog_id )
136            || wpcom_has_blog_sticker( 'big-sky-free-trial', $blog_id );
137    }
138
139    /**
140     * Detect the current editor context.
141     *
142     * @return string One of 'site-editor', 'block-editor', or 'wp-admin'.
143     */
144    private function get_editor_context() {
145        global $pagenow;
146
147        if ( $pagenow === 'site-editor.php' ) {
148            return 'site-editor';
149        }
150
151        if ( function_exists( 'get_current_screen' ) ) {
152            $current_screen = get_current_screen();
153            if ( $current_screen && $current_screen->is_block_editor() && $current_screen->id !== 'widgets' ) {
154                return 'block-editor';
155            }
156        }
157
158        return 'wp-admin';
159    }
160
161    /**
162     * The current user's WordPress.com user ID, or null when they have none.
163     *
164     * Meta first: most SSO users on Atomic hold no Jetpack token, which the
165     * connection resolver requires; it is the fallback that persists the binding.
166     *
167     * @return int|null
168     */
169    private function get_wpcom_user_id() {
170        $user_id = get_current_user_id();
171        if ( ! $user_id ) {
172            return null;
173        }
174
175        if ( Constants::is_true( 'IS_WPCOM' ) ) {
176            return $user_id;
177        }
178
179        $wpcom_user_id = Connection_Utils::get_wpcom_user_id( $user_id );
180        if ( ! $wpcom_user_id ) {
181            $wpcom_user_id = ( new Connection_Manager() )->resolve_wpcom_user_id( $user_id );
182        }
183
184        return $wpcom_user_id > 0 ? $wpcom_user_id : null;
185    }
186
187    /**
188     * Get visitor traits for Survicate.
189     *
190     * @return array
191     */
192    private function get_visitor_traits() {
193        $user_data     = get_userdata( get_current_user_id() );
194        $email         = $user_data ? $user_data->user_email : '';
195        $site_id       = get_wpcom_blog_id();
196        $site_type     = ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) ? 'atomic' : 'simple';
197        $wpcom_user_id = $this->get_wpcom_user_id();
198
199        $traits = array(
200            'email'           => $email,
201            'site_id'         => $site_id ? (string) $site_id : '',
202            'site_type'       => $site_type,
203            'editor_context'  => $this->get_editor_context(),
204            // Stringified for Survicate's trait targeting UI, which matches on string equality.
205            'is_big_sky_site' => $this->is_big_sky_site() ? 'true' : 'false',
206        );
207
208        // Survicate only treats a visitor as identified, and so eligible for
209        // "Users" audiences, when `user_id` is set. It must match the ID Calypso
210        // sends so a respondent's history is shared across both.
211        if ( $wpcom_user_id ) {
212            $traits['user_id'] = (string) $wpcom_user_id;
213        }
214
215        return $traits;
216    }
217
218    /**
219     * Reads the bundle's asset manifest: from disk on WordPress.com, over the
220     * network on Atomic. Cached for an hour. Returns null when unavailable so
221     * the caller can skip Survicate entirely — surveys are optional and must
222     * never break wp-admin.
223     *
224     * Failures are cached for a few minutes too, so an unreachable
225     * widgets.wp.com does not add a blocking fetch to every admin page load.
226     *
227     * @return array|null Decoded manifest with `dependencies` and `version`, or null.
228     */
229    private function get_asset_json() {
230        $asset = get_transient( self::ASSET_TRANSIENT_KEY );
231        if ( self::ASSET_UNAVAILABLE === $asset ) {
232            return null;
233        }
234        if ( is_array( $asset ) ) {
235            return $asset;
236        }
237
238        $local_path = ABSPATH . '/' . self::ASSET_JSON_PATH;
239        if ( file_exists( $local_path ) ) {
240            $asset = json_decode( file_get_contents( $local_path ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
241        } else {
242            $response = wp_remote_get( 'https://' . self::ASSET_JSON_PATH, array( 'timeout' => 2 ) );
243            $asset    = is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response )
244                ? null
245                : json_decode( wp_remote_retrieve_body( $response ), true );
246        }
247
248        if ( ! is_array( $asset ) || empty( $asset['version'] ) ) {
249            set_transient( self::ASSET_TRANSIENT_KEY, self::ASSET_UNAVAILABLE, 5 * MINUTE_IN_SECONDS );
250            return null;
251        }
252
253        set_transient( self::ASSET_TRANSIENT_KEY, $asset, HOUR_IN_SECONDS );
254
255        return $asset;
256    }
257
258    /**
259     * Enqueue the shared Survicate bundle and the config it reads.
260     *
261     * PHP decides whether the user, screen and site are eligible and which
262     * site-level traits to attach; the bundle owns the SDK lifecycle and the
263     * survey suppression rules (see `packages/survicate` in wp-calypso).
264     */
265    public function enqueue_scripts() {
266        if ( ! $this->should_load() ) {
267            return;
268        }
269
270        $asset = $this->get_asset_json();
271        if ( null === $asset ) {
272            return;
273        }
274
275        wp_enqueue_script(
276            'wpcom-survicate',
277            self::BUNDLE_URL,
278            $asset['dependencies'] ?? array(),
279            self::is_proxied() ? wp_rand() : $asset['version'],
280            true
281        );
282
283        $config = array(
284            'locale' => get_user_locale(),
285            'traits' => $this->get_visitor_traits(),
286        );
287
288        wp_add_inline_script(
289            'wpcom-survicate',
290            'window.wpcomSurvicateConfig = ' . wp_json_encode( $config, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';',
291            'before'
292        );
293    }
294}
295
296add_action( 'init', array( __NAMESPACE__ . '\Survicate', 'init' ) );