Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
96.99% |
129 / 133 |
|
69.23% |
9 / 13 |
CRAP | |
0.00% |
0 / 1 |
| Stats_Settings | |
96.99% |
129 / 133 |
|
69.23% |
9 / 13 |
47 | |
0.00% |
0 / 1 |
| configure | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| register | |
100.00% |
49 / 49 |
|
100.00% |
1 / 1 |
3 | |||
| get_fields | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| get_stats_option_fields | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| get_role_slugs | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
5 | |||
| get_stats_options | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| update_stats_options | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
5.05 | |||
| report_update_error | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| sanitize_reader_views | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| add_script_data | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
9 | |||
| get_roles_with_counts | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
5.02 | |||
| get_consent_status | |
96.30% |
26 / 27 |
|
0.00% |
0 / 1 |
6 | |||
| banner_blocks_stats | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
4 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * The Stats settings the dashboard's Settings tab edits. |
| 4 | * |
| 5 | * @package automattic/jetpack-premium-analytics |
| 6 | */ |
| 7 | |
| 8 | namespace Automattic\Jetpack\PremiumAnalytics; |
| 9 | |
| 10 | use Automattic\Jetpack\Stats\Options as Stats_Options; |
| 11 | use Automattic\Jetpack\Stats\Settings as Stats_Package_Settings; |
| 12 | |
| 13 | /** |
| 14 | * Exposes the Stats settings through core's `wp/v2/settings` route, and the settings and roles the Settings tab offers through script data. |
| 15 | */ |
| 16 | final class Stats_Settings { |
| 17 | |
| 18 | /** |
| 19 | * Filter for the settings and the consent card the site offers, so a host can leave out what its Stats does not honor. |
| 20 | * |
| 21 | * @var string |
| 22 | */ |
| 23 | public const FIELDS_FILTER = 'jetpack_premium_analytics_stats_settings_fields'; |
| 24 | |
| 25 | /** |
| 26 | * Settings group the options register under. |
| 27 | * |
| 28 | * @var string |
| 29 | */ |
| 30 | private const GROUP = 'jetpack_premium_analytics'; |
| 31 | |
| 32 | /** |
| 33 | * Site option holding whether the WordPress.com Reader shows post views. |
| 34 | * |
| 35 | * @var string |
| 36 | */ |
| 37 | private const READER_VIEWS_OPTION = 'wpcom_reader_views_enabled'; |
| 38 | |
| 39 | /** |
| 40 | * The visitor consent card's entry in the host's list. It is not a setting, so the route never offers it. |
| 41 | * |
| 42 | * @var string |
| 43 | */ |
| 44 | private const CONSENT = 'consent'; |
| 45 | |
| 46 | /** |
| 47 | * The `stats_options` fields the Settings tab edits. The option also holds internal state, which the route never exposes. |
| 48 | * |
| 49 | * @var string[] |
| 50 | */ |
| 51 | private const FIELDS = array( 'admin_bar', 'roles', 'count_roles' ); |
| 52 | |
| 53 | /** |
| 54 | * Cookie banner plugins that support the WP Consent API, by plugin directory: the WordPress.org ones the WP Consent API readme lists. |
| 55 | * |
| 56 | * @var string[] |
| 57 | */ |
| 58 | private const BANNER_PLUGINS = array( |
| 59 | 'arqam360-consent-privacy-pdplgdpr' => 'Arqam360 Cookie Consent Banner', |
| 60 | 'beautiful-and-responsive-cookie-consent' => 'Beautiful Cookie Consent Banner', |
| 61 | 'clickio-consent' => 'Clickio Consent', |
| 62 | 'complianz-gdpr' => 'Complianz', |
| 63 | 'complianz-gdpr-premium' => 'Complianz', |
| 64 | 'consent-manager' => 'consentmanager', |
| 65 | 'cookie-law-info' => 'CookieYes', |
| 66 | 'cookiebot' => 'Cookiebot', |
| 67 | 'cookiefirst-gdpr-cookie-consent-banner' => 'CookieFirst', |
| 68 | 'cookiehub' => 'CookieHub', |
| 69 | 'cookietractor' => 'CookieTractor', |
| 70 | 'gdpr-cookie-compliance' => 'GDPR Cookie Compliance', |
| 71 | 'ibamu' => 'Lawwwing', |
| 72 | 'iubenda-cookie-law-solution' => 'iubenda', |
| 73 | 'pressidium-cookie-consent' => 'Pressidium Cookie Consent', |
| 74 | 'sirdata-cmp' => 'Abconsent Sirdata CMP', |
| 75 | 'trustarc-cookie-consent-manager' => 'TrustArc Cookie Consent Manager', |
| 76 | ); |
| 77 | |
| 78 | /** |
| 79 | * The banner plugins the card suggests, by WordPress.org slug. |
| 80 | * |
| 81 | * @var string[] |
| 82 | */ |
| 83 | private const RECOMMENDED_BANNERS = array( 'complianz-gdpr', 'cookiebot', 'cookie-law-info' ); |
| 84 | |
| 85 | /** |
| 86 | * The Stats package's refusal of the current write, held until the route answers. |
| 87 | * |
| 88 | * @var \WP_Error|null |
| 89 | */ |
| 90 | private static $update_error = null; |
| 91 | |
| 92 | /** |
| 93 | * Hook up the settings route and the Settings tab's script data. |
| 94 | * |
| 95 | * Call it before `rest_api_init`, or hook register() itself on that hook, which add_action() merges with this one. Add any FIELDS_FILTER callback before either. |
| 96 | * |
| 97 | * @return void |
| 98 | */ |
| 99 | public static function configure() { |
| 100 | add_action( 'rest_api_init', array( __CLASS__, 'register' ) ); |
| 101 | // Counting users per role costs a query, so only the dashboard pays for it. |
| 102 | if ( Analytics::is_dashboard_request() ) { |
| 103 | add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'add_script_data' ), 20 ); |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | /** |
| 108 | * Declare the settings so core's settings route exposes them. |
| 109 | * |
| 110 | * @return void |
| 111 | */ |
| 112 | public static function register() { |
| 113 | $fields = self::get_fields(); |
| 114 | $stats_fields = array_values( array_intersect( self::FIELDS, $fields ) ); |
| 115 | if ( array() !== $stats_fields ) { |
| 116 | $role = array( |
| 117 | 'type' => 'string', |
| 118 | 'enum' => self::get_role_slugs(), |
| 119 | ); |
| 120 | $properties = array( |
| 121 | 'admin_bar' => array( 'type' => 'boolean' ), |
| 122 | 'roles' => array( |
| 123 | 'type' => 'array', |
| 124 | 'items' => $role, |
| 125 | 'minItems' => 1, |
| 126 | ), |
| 127 | 'count_roles' => array( |
| 128 | 'type' => 'array', |
| 129 | 'items' => $role, |
| 130 | ), |
| 131 | ); |
| 132 | |
| 133 | register_setting( |
| 134 | self::GROUP, |
| 135 | Stats_Options::OPTION_NAME, |
| 136 | array( |
| 137 | 'type' => 'object', |
| 138 | 'show_in_rest' => array( |
| 139 | 'schema' => array( |
| 140 | 'type' => 'object', |
| 141 | 'properties' => array_intersect_key( $properties, array_flip( $stats_fields ) ), |
| 142 | 'additionalProperties' => false, |
| 143 | ), |
| 144 | ), |
| 145 | 'description' => __( 'Jetpack Stats settings.', 'jetpack-premium-analytics-pkg' ), |
| 146 | ) |
| 147 | ); |
| 148 | } |
| 149 | if ( in_array( self::READER_VIEWS_OPTION, $fields, true ) ) { |
| 150 | register_setting( |
| 151 | self::GROUP, |
| 152 | self::READER_VIEWS_OPTION, |
| 153 | array( |
| 154 | 'type' => 'boolean', |
| 155 | 'default' => true, |
| 156 | 'show_in_rest' => true, |
| 157 | 'sanitize_callback' => array( __CLASS__, 'sanitize_reader_views' ), |
| 158 | 'description' => __( 'Whether the WordPress.com Reader shows post views for this site.', 'jetpack-premium-analytics-pkg' ), |
| 159 | ) |
| 160 | ); |
| 161 | } |
| 162 | add_filter( 'rest_pre_get_setting', array( __CLASS__, 'get_stats_options' ), 10, 2 ); |
| 163 | add_filter( 'rest_pre_update_setting', array( __CLASS__, 'update_stats_options' ), 10, 3 ); |
| 164 | add_filter( 'rest_request_after_callbacks', array( __CLASS__, 'report_update_error' ) ); |
| 165 | } |
| 166 | |
| 167 | /** |
| 168 | * What the site offers: the `stats_options` fields, the Reader setting and the consent card, less any the host leaves out. |
| 169 | * |
| 170 | * @return string[] |
| 171 | */ |
| 172 | private static function get_fields() { |
| 173 | $fields = array_merge( self::FIELDS, array( self::READER_VIEWS_OPTION, self::CONSENT ) ); |
| 174 | |
| 175 | /** |
| 176 | * Filters the Stats settings the dashboard's Settings tab and the settings route offer, and whether the tab shows the visitor consent card. |
| 177 | * |
| 178 | * Keep at least one `stats_options` field with the Reader setting: the tab cannot load the Reader setting alone. |
| 179 | * |
| 180 | * @since $$next-version$$ |
| 181 | * |
| 182 | * @param string[] $fields `admin_bar`, `roles`, `count_roles`, `wpcom_reader_views_enabled` and `consent`. |
| 183 | */ |
| 184 | $kept = apply_filters( self::FIELDS_FILTER, $fields ); |
| 185 | |
| 186 | return array_values( array_intersect( $fields, is_array( $kept ) ? $kept : array() ) ); |
| 187 | } |
| 188 | |
| 189 | /** |
| 190 | * The `stats_options` fields the site offers. |
| 191 | * |
| 192 | * @return string[] |
| 193 | */ |
| 194 | private static function get_stats_option_fields() { |
| 195 | return array_values( array_intersect( self::FIELDS, self::get_fields() ) ); |
| 196 | } |
| 197 | |
| 198 | /** |
| 199 | * The role slugs the role fields accept: the site's roles, plus any already stored, so a removed role still reads back. |
| 200 | * |
| 201 | * @return string[] |
| 202 | */ |
| 203 | private static function get_role_slugs() { |
| 204 | $stored = get_option( Stats_Options::OPTION_NAME, array() ); |
| 205 | $slugs = array_keys( wp_roles()->roles ); |
| 206 | |
| 207 | foreach ( array( 'roles', 'count_roles' ) as $field ) { |
| 208 | if ( is_array( $stored ) && isset( $stored[ $field ] ) && is_array( $stored[ $field ] ) ) { |
| 209 | $slugs = array_merge( $slugs, array_filter( $stored[ $field ], 'is_string' ) ); |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | return array_values( array_unique( $slugs ) ); |
| 214 | } |
| 215 | |
| 216 | /** |
| 217 | * Answer the settings route with the editable fields only, defaults included. |
| 218 | * |
| 219 | * @param mixed $value The value another filter supplied, or null. |
| 220 | * @param string $name The setting's name on the route. |
| 221 | * @return mixed The editable fields for `stats_options`, otherwise `$value`. |
| 222 | */ |
| 223 | public static function get_stats_options( $value, $name ) { |
| 224 | if ( Stats_Options::OPTION_NAME !== $name ) { |
| 225 | return $value; |
| 226 | } |
| 227 | |
| 228 | return Stats_Package_Settings::get( self::get_stats_option_fields() ); |
| 229 | } |
| 230 | |
| 231 | /** |
| 232 | * Save a write to `stats_options` through the Stats package, which keeps the option's internal state and administrators' access, and check that the Reader setting was stored. |
| 233 | * |
| 234 | * Core ignores an error from this filter, so a refusal is held for `report_update_error()`. |
| 235 | * |
| 236 | * @param bool $updated Whether another filter saved the setting. |
| 237 | * @param string $name The setting's name on the route. |
| 238 | * @param mixed $value The value sent. |
| 239 | * @return bool Whether the setting was handled. |
| 240 | */ |
| 241 | public static function update_stats_options( $updated, $name, $value ) { |
| 242 | if ( self::READER_VIEWS_OPTION === $name ) { |
| 243 | $result = Stats_Package_Settings::update_reader_views( rest_sanitize_boolean( $value ) ); |
| 244 | } elseif ( Stats_Options::OPTION_NAME === $name ) { |
| 245 | $result = Stats_Package_Settings::update( is_array( $value ) ? $value : array(), self::get_stats_option_fields() ); |
| 246 | } else { |
| 247 | return $updated; |
| 248 | } |
| 249 | |
| 250 | if ( is_wp_error( $result ) ) { |
| 251 | self::$update_error = $result; |
| 252 | } |
| 253 | |
| 254 | return true; |
| 255 | } |
| 256 | |
| 257 | /** |
| 258 | * Answer the settings route with the Stats package's refusal, when it refused the write. |
| 259 | * |
| 260 | * @param mixed $response The route's response. |
| 261 | * @return mixed The refusal, otherwise `$response`. |
| 262 | */ |
| 263 | public static function report_update_error( $response ) { |
| 264 | if ( null === self::$update_error ) { |
| 265 | return $response; |
| 266 | } |
| 267 | |
| 268 | $error = self::$update_error; |
| 269 | self::$update_error = null; |
| 270 | |
| 271 | return $error; |
| 272 | } |
| 273 | |
| 274 | /** |
| 275 | * Store the Reader setting as 0 or 1: a bare `false` would reach the options table as `''`, which the schema does not read back as a boolean. |
| 276 | * |
| 277 | * @param mixed $value The value being written. |
| 278 | * @return int |
| 279 | */ |
| 280 | public static function sanitize_reader_views( $value ) { |
| 281 | return (int) rest_sanitize_boolean( $value ); |
| 282 | } |
| 283 | |
| 284 | /** |
| 285 | * Give the Settings tab the settings it shows, the roles it lists with how many users hold each, the screen that switches the Stats module on and off, and the visitor consent setup. |
| 286 | * |
| 287 | * @param array $data The script data. |
| 288 | * @return array The script data with the Stats settings context added. |
| 289 | */ |
| 290 | public static function add_script_data( $data ) { |
| 291 | $fields = self::get_fields(); |
| 292 | if ( array() === $fields || ! current_user_can( 'manage_options' ) ) { |
| 293 | return $data; |
| 294 | } |
| 295 | |
| 296 | if ( ! isset( $data['premium_analytics'] ) || ! is_array( $data['premium_analytics'] ) ) { |
| 297 | $data['premium_analytics'] = array(); |
| 298 | } |
| 299 | |
| 300 | $data['premium_analytics']['stats_settings'] = array( |
| 301 | 'fields' => $fields, |
| 302 | 'roles' => array_intersect( array( 'roles', 'count_roles' ), $fields ) ? self::get_roles_with_counts() : array(), |
| 303 | // Jetpack can be active without My Jetpack's screen, for example on Atomic, where wpcomsh skips it. |
| 304 | 'features_url' => class_exists( 'Jetpack' ) && '' !== menu_page_url( 'my-jetpack', false ) ? admin_url( 'admin.php?page=my-jetpack#/features?search=stats' ) : null, |
| 305 | ); |
| 306 | if ( in_array( self::CONSENT, $fields, true ) ) { |
| 307 | $data['premium_analytics']['stats_settings']['consent'] = self::get_consent_status(); |
| 308 | } |
| 309 | |
| 310 | return $data; |
| 311 | } |
| 312 | |
| 313 | /** |
| 314 | * The site's editable roles, with how many users hold each. |
| 315 | * |
| 316 | * @return array[] |
| 317 | */ |
| 318 | private static function get_roles_with_counts() { |
| 319 | if ( ! function_exists( 'get_editable_roles' ) ) { |
| 320 | require_once ABSPATH . 'wp-admin/includes/user.php'; |
| 321 | } |
| 322 | |
| 323 | // Counting users is a slow query on large sites, so their roles come without counts. |
| 324 | $counts = wp_is_large_user_count() ? null : count_users()['avail_roles']; |
| 325 | $roles = array(); |
| 326 | foreach ( get_editable_roles() as $slug => $role ) { |
| 327 | $roles[] = array( |
| 328 | 'slug' => $slug, |
| 329 | 'name' => translate_user_role( $role['name'] ), |
| 330 | 'count' => null === $counts ? null : (int) ( $counts[ $slug ] ?? 0 ), |
| 331 | ); |
| 332 | } |
| 333 | |
| 334 | return $roles; |
| 335 | } |
| 336 | |
| 337 | /** |
| 338 | * Whether visitor consent can reach Stats: the WP Consent API bridge, and a cookie banner plugin behind it. |
| 339 | * |
| 340 | * @return array{api_active: bool, api_installed: bool, consent_type_declared: bool, banner_plugin: string|null, banner_blocks_stats: bool, recommended_banners: list<array{name: string, url: string}>, can_install: bool, can_activate: bool, file_mods_allowed: bool} |
| 341 | */ |
| 342 | private static function get_consent_status() { |
| 343 | $plugin_file = 'wp-consent-api/wp-consent-api.php'; |
| 344 | $active_plugins = (array) get_option( 'active_plugins', array() ); |
| 345 | if ( is_multisite() ) { |
| 346 | $active_plugins = array_merge( $active_plugins, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) ); |
| 347 | } |
| 348 | $banner_plugin = null; |
| 349 | foreach ( $active_plugins as $active ) { |
| 350 | $dir = strtok( $active, '/' ); |
| 351 | if ( isset( self::BANNER_PLUGINS[ $dir ] ) ) { |
| 352 | $banner_plugin = self::BANNER_PLUGINS[ $dir ]; |
| 353 | break; |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | $recommended_banners = array(); |
| 358 | foreach ( self::RECOMMENDED_BANNERS as $slug ) { |
| 359 | $recommended_banners[] = array( |
| 360 | 'name' => self::BANNER_PLUGINS[ $slug ], |
| 361 | 'url' => 'https://wordpress.org/plugins/' . $slug . '/', |
| 362 | ); |
| 363 | } |
| 364 | |
| 365 | return array( |
| 366 | 'api_active' => function_exists( 'wp_has_consent' ), |
| 367 | 'api_installed' => file_exists( WP_PLUGIN_DIR . '/' . $plugin_file ), |
| 368 | // Some banners answer this filter even before the WP Consent API is active; others set the type only in the browser, hence `banner_plugin`. |
| 369 | 'consent_type_declared' => (bool) apply_filters( 'wp_get_consent_type', '' ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- The WP Consent API's filter, which banner plugins answer. |
| 370 | 'banner_plugin' => $banner_plugin, |
| 371 | 'banner_blocks_stats' => self::banner_blocks_stats(), |
| 372 | 'recommended_banners' => $recommended_banners, |
| 373 | // What core's `wp/v2/plugins` route checks before it installs or activates. |
| 374 | 'can_install' => current_user_can( 'install_plugins' ) && current_user_can( 'activate_plugins' ), |
| 375 | 'can_activate' => current_user_can( 'activate_plugin', $plugin_file ), |
| 376 | // The check core's `install_plugins` capability makes first, so the card can say why it cannot install. |
| 377 | 'file_mods_allowed' => wp_is_file_mod_allowed( 'capability_update_core' ), |
| 378 | ); |
| 379 | } |
| 380 | |
| 381 | /** |
| 382 | * Whether Complianz's Jetpack integration holds the Stats script until visitors allow statistics, with or without the WP Consent API. |
| 383 | * |
| 384 | * @return bool |
| 385 | */ |
| 386 | private static function banner_blocks_stats() { |
| 387 | if ( ! function_exists( 'cmplz_integration_plugin_is_enabled' ) || ! function_exists( 'cmplz_can_run_cookie_blocker' ) ) { |
| 388 | return false; |
| 389 | } |
| 390 | |
| 391 | // `true` asks whether the blocker runs on the front end, since it never runs in wp-admin. |
| 392 | // @phan-suppress-next-line PhanUndeclaredFunction -- Defined by Complianz, checked just before. |
| 393 | return cmplz_integration_plugin_is_enabled( 'jetpack' ) && cmplz_can_run_cookie_blocker( true ); |
| 394 | } |
| 395 | } |