Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.99% covered (success)
96.99%
129 / 133
69.23% covered (warning)
69.23%
9 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Stats_Settings
96.99% covered (success)
96.99%
129 / 133
69.23% covered (warning)
69.23%
9 / 13
47
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%
13 / 13
100.00% covered (success)
100.00%
1 / 1
9
 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
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     * 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}