Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.16% covered (success)
97.16%
137 / 141
71.43% covered (warning)
71.43%
10 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Stats_Settings
97.16% covered (success)
97.16%
137 / 141
71.43% covered (warning)
71.43%
10 / 14
51
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
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 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            $result = Stats_Package_Settings::update_reader_views( rest_sanitize_boolean( $value ) );
252        } elseif ( Stats_Options::OPTION_NAME === $name ) {
253            $result = Stats_Package_Settings::update( is_array( $value ) ? $value : array(), self::get_stats_option_fields() );
254        } else {
255            return $updated;
256        }
257
258        if ( is_wp_error( $result ) ) {
259            self::$update_error = $result;
260        }
261
262        return true;
263    }
264
265    /**
266     * Answer the settings route with the Stats package's refusal, when it refused the write.
267     *
268     * @param mixed $response The route's response.
269     * @return mixed The refusal, otherwise `$response`.
270     */
271    public static function report_update_error( $response ) {
272        if ( null === self::$update_error ) {
273            return $response;
274        }
275
276        $error              = self::$update_error;
277        self::$update_error = null;
278
279        return $error;
280    }
281
282    /**
283     * 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.
284     *
285     * @param mixed $value The value being written.
286     * @return int
287     */
288    public static function sanitize_reader_views( $value ) {
289        return (int) rest_sanitize_boolean( $value );
290    }
291
292    /**
293     * 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.
294     *
295     * @param array $data The script data.
296     * @return array The script data with the Stats settings context added.
297     */
298    public static function add_script_data( $data ) {
299        $fields = self::get_fields();
300        if ( array() === $fields || ! current_user_can( 'manage_options' ) ) {
301            return $data;
302        }
303
304        if ( ! isset( $data['premium_analytics'] ) || ! is_array( $data['premium_analytics'] ) ) {
305            $data['premium_analytics'] = array();
306        }
307
308        $features_screen = self::get_features_screen();
309
310        $data['premium_analytics']['stats_settings'] = array(
311            'fields'          => $fields,
312            'roles'           => array_intersect( array( 'roles', 'count_roles' ), $fields ) ? self::get_roles_with_counts() : array(),
313            'features_url'    => null === $features_screen ? null : admin_url( self::FEATURES_SCREENS[ $features_screen ] ),
314            'features_screen' => $features_screen,
315        );
316        if ( in_array( self::CONSENT, $fields, true ) ) {
317            $data['premium_analytics']['stats_settings']['consent'] = self::get_consent_status();
318        }
319
320        return $data;
321    }
322
323    /**
324     * The site's editable roles, with how many users hold each.
325     *
326     * @return array[]
327     */
328    private static function get_roles_with_counts() {
329        if ( ! function_exists( 'get_editable_roles' ) ) {
330            require_once ABSPATH . 'wp-admin/includes/user.php';
331        }
332
333        // Counting users is a slow query on large sites, so their roles come without counts.
334        $counts = wp_is_large_user_count() ? null : count_users()['avail_roles'];
335        $roles  = array();
336        foreach ( get_editable_roles() as $slug => $role ) {
337            $roles[] = array(
338                'slug'  => $slug,
339                'name'  => translate_user_role( $role['name'] ),
340                'count' => null === $counts ? null : (int) ( $counts[ $slug ] ?? 0 ),
341            );
342        }
343
344        return $roles;
345    }
346
347    /**
348     * Whether visitor consent can reach Stats: the WP Consent API bridge, and a cookie banner plugin behind it.
349     *
350     * @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}
351     */
352    private static function get_consent_status() {
353        $plugin_file    = 'wp-consent-api/wp-consent-api.php';
354        $active_plugins = (array) get_option( 'active_plugins', array() );
355        if ( is_multisite() ) {
356            $active_plugins = array_merge( $active_plugins, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) );
357        }
358        $banner_plugin = null;
359        foreach ( $active_plugins as $active ) {
360            $dir = strtok( $active, '/' );
361            if ( isset( self::BANNER_PLUGINS[ $dir ] ) ) {
362                $banner_plugin = self::BANNER_PLUGINS[ $dir ];
363                break;
364            }
365        }
366
367        $recommended_banners = array();
368        foreach ( self::RECOMMENDED_BANNERS as $slug ) {
369            $recommended_banners[] = array(
370                'name' => self::BANNER_PLUGINS[ $slug ],
371                'url'  => 'https://wordpress.org/plugins/' . $slug . '/',
372            );
373        }
374
375        return array(
376            'api_active'            => function_exists( 'wp_has_consent' ),
377            'api_installed'         => file_exists( WP_PLUGIN_DIR . '/' . $plugin_file ),
378            // Some banners answer this filter even before the WP Consent API is active; others set the type only in the browser, hence `banner_plugin`.
379            '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.
380            'banner_plugin'         => $banner_plugin,
381            'banner_blocks_stats'   => self::banner_blocks_stats(),
382            'recommended_banners'   => $recommended_banners,
383            // What core's `wp/v2/plugins` route checks before it installs or activates.
384            'can_install'           => current_user_can( 'install_plugins' ) && current_user_can( 'activate_plugins' ),
385            'can_activate'          => current_user_can( 'activate_plugin', $plugin_file ),
386            // The check core's `install_plugins` capability makes first, so the card can say why it cannot install.
387            'file_mods_allowed'     => wp_is_file_mod_allowed( 'capability_update_core' ),
388        );
389    }
390
391    /**
392     * Whether Complianz's Jetpack integration holds the Stats script until visitors allow statistics, with or without the WP Consent API.
393     *
394     * @return bool
395     */
396    private static function banner_blocks_stats() {
397        if ( ! function_exists( 'cmplz_integration_plugin_is_enabled' ) || ! function_exists( 'cmplz_can_run_cookie_blocker' ) ) {
398            return false;
399        }
400
401        // `true` asks whether the blocker runs on the front end, since it never runs in wp-admin.
402        // @phan-suppress-next-line PhanUndeclaredFunction -- Defined by Complianz, checked just before.
403        return cmplz_integration_plugin_is_enabled( 'jetpack' ) && cmplz_can_run_cookie_blocker( true );
404    }
405
406    /**
407     * 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.
408     *
409     * @return 'features'|'modules'|null The screen, or null when the site has neither.
410     */
411    private static function get_features_screen() {
412        if ( ! class_exists( 'Jetpack' ) ) {
413            return null;
414        }
415
416        // WordPress.com turns this off on Atomic Personal and Premium plans, which hide My Jetpack and redirect it to Stats.
417        /** This filter is documented in projects/packages/my-jetpack/src/class-initializer.php */
418        $features_available = apply_filters( 'jetpack_my_jetpack_modules_management_available', true );
419        // Jetpack can be active without My Jetpack's screen, for example in Offline Mode.
420        if ( $features_available && '' !== menu_page_url( 'my-jetpack', false ) ) {
421            return 'features';
422        }
423
424        return '' !== menu_page_url( 'jetpack_modules', false ) ? 'modules' : null;
425    }
426}