Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
94.79% |
91 / 96 |
|
60.00% |
6 / 10 |
CRAP | |
0.00% |
0 / 1 |
| Survicate | |
95.79% |
91 / 95 |
|
60.00% |
6 / 10 |
48 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| is_proxied | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
3.33 | |||
| init | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| should_load | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
8 | |||
| is_big_sky_site | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
4.05 | |||
| get_editor_context | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
6 | |||
| get_wpcom_user_id | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
5.03 | |||
| get_visitor_traits | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
7 | |||
| get_asset_json | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
8.01 | |||
| enqueue_scripts | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
4 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Survicate survey integration |
| 4 | * |
| 5 | * @package automattic/jetpack-mu-wpcom |
| 6 | */ |
| 7 | |
| 8 | namespace A8C\FSE; |
| 9 | |
| 10 | use Automattic\Jetpack\Connection\Manager as Connection_Manager; |
| 11 | use Automattic\Jetpack\Connection\Utils as Connection_Utils; |
| 12 | use Automattic\Jetpack\Constants; |
| 13 | |
| 14 | /** |
| 15 | * Class Survicate |
| 16 | */ |
| 17 | class 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 | |
| 296 | add_action( 'init', array( __NAMESPACE__ . '\Survicate', 'init' ) ); |