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