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