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