Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.00% covered (success)
97.00%
97 / 100
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Stats_Settings
97.00% covered (success)
97.00%
97 / 100
62.50% covered (warning)
62.50%
5 / 8
31
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
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
7.01
 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
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
10.01
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 Stats package's refusal of the current write, held until the route answers.
42     *
43     * @var \WP_Error|null
44     */
45    private static $update_error = null;
46
47    /**
48     * Hook the settings up, except on Simple sites, where WordPress.com owns the Stats settings.
49     *
50     * @return void
51     */
52    public static function configure() {
53        if ( ( new Host() )->is_wpcom_simple() ) {
54            return;
55        }
56
57        add_action( 'rest_api_init', array( __CLASS__, 'register' ) );
58        // Counting users per role costs a query, so only the dashboard pays for it.
59        if ( Analytics::is_dashboard_request() ) {
60            add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'add_script_data' ), 20 );
61        }
62    }
63
64    /**
65     * Declare the settings so core's settings route exposes them.
66     *
67     * @return void
68     */
69    public static function register() {
70        $role = array(
71            'type' => 'string',
72            'enum' => self::get_role_slugs(),
73        );
74
75        register_setting(
76            self::GROUP,
77            Stats_Options::OPTION_NAME,
78            array(
79                'type'         => 'object',
80                'show_in_rest' => array(
81                    'schema' => array(
82                        'type'                 => 'object',
83                        'properties'           => array(
84                            'admin_bar'   => array( 'type' => 'boolean' ),
85                            'roles'       => array(
86                                'type'     => 'array',
87                                'items'    => $role,
88                                'minItems' => 1,
89                            ),
90                            'count_roles' => array(
91                                'type'  => 'array',
92                                'items' => $role,
93                            ),
94                        ),
95                        'additionalProperties' => false,
96                    ),
97                ),
98                'description'  => __( 'Jetpack Stats settings.', 'jetpack-premium-analytics-pkg' ),
99            )
100        );
101        register_setting(
102            self::GROUP,
103            self::READER_VIEWS_OPTION,
104            array(
105                'type'              => 'boolean',
106                'default'           => true,
107                'show_in_rest'      => true,
108                'sanitize_callback' => array( __CLASS__, 'sanitize_reader_views' ),
109                'description'       => __( 'Whether the WordPress.com Reader shows post views for this site.', 'jetpack-premium-analytics-pkg' ),
110            )
111        );
112        add_filter( 'rest_pre_get_setting', array( __CLASS__, 'get_stats_options' ), 10, 2 );
113        add_filter( 'rest_pre_update_setting', array( __CLASS__, 'update_stats_options' ), 10, 3 );
114        add_filter( 'rest_request_after_callbacks', array( __CLASS__, 'report_update_error' ) );
115    }
116
117    /**
118     * The role slugs the role fields accept: the site's roles, plus any already stored, so a removed role still reads back.
119     *
120     * @return string[]
121     */
122    private static function get_role_slugs() {
123        $stored = get_option( Stats_Options::OPTION_NAME, array() );
124        $slugs  = array_keys( wp_roles()->roles );
125
126        foreach ( array( 'roles', 'count_roles' ) as $field ) {
127            if ( is_array( $stored ) && isset( $stored[ $field ] ) && is_array( $stored[ $field ] ) ) {
128                $slugs = array_merge( $slugs, array_filter( $stored[ $field ], 'is_string' ) );
129            }
130        }
131
132        return array_values( array_unique( $slugs ) );
133    }
134
135    /**
136     * Answer the settings route with the editable fields only, defaults included.
137     *
138     * @param mixed  $value The value another filter supplied, or null.
139     * @param string $name  The setting's name on the route.
140     * @return mixed The editable fields for `stats_options`, otherwise `$value`.
141     */
142    public static function get_stats_options( $value, $name ) {
143        if ( Stats_Options::OPTION_NAME !== $name ) {
144            return $value;
145        }
146
147        return Stats_Package_Settings::get( self::FIELDS );
148    }
149
150    /**
151     * 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.
152     *
153     * Core ignores an error from this filter, so a refusal is held for `report_update_error()`.
154     *
155     * @param bool   $updated Whether another filter saved the setting.
156     * @param string $name    The setting's name on the route.
157     * @param mixed  $value   The value sent.
158     * @return bool Whether the setting was handled.
159     */
160    public static function update_stats_options( $updated, $name, $value ) {
161        if ( self::READER_VIEWS_OPTION === $name ) {
162            $enabled = self::sanitize_reader_views( $value );
163            update_option( self::READER_VIEWS_OPTION, $enabled );
164            if ( (int) get_option( self::READER_VIEWS_OPTION ) !== $enabled ) {
165                self::$update_error = new \WP_Error(
166                    'jetpack_premium_analytics_reader_views_save_failed',
167                    __( 'The WordPress.com Reader setting could not be saved.', 'jetpack-premium-analytics-pkg' ),
168                    array( 'status' => 500 )
169                );
170            }
171            return true;
172        }
173
174        if ( Stats_Options::OPTION_NAME !== $name ) {
175            return $updated;
176        }
177
178        $result = Stats_Package_Settings::update( is_array( $value ) ? $value : array(), self::FIELDS );
179        if ( is_wp_error( $result ) ) {
180            $result->add_data( array( 'status' => false !== strpos( $result->get_error_code(), 'save_failed' ) ? 500 : 400 ) );
181            self::$update_error = $result;
182        }
183
184        return true;
185    }
186
187    /**
188     * Answer the settings route with the Stats package's refusal, when it refused the write.
189     *
190     * @param mixed $response The route's response.
191     * @return mixed The refusal, otherwise `$response`.
192     */
193    public static function report_update_error( $response ) {
194        if ( null === self::$update_error ) {
195            return $response;
196        }
197
198        $error              = self::$update_error;
199        self::$update_error = null;
200
201        return $error;
202    }
203
204    /**
205     * 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.
206     *
207     * @param mixed $value The value being written.
208     * @return int
209     */
210    public static function sanitize_reader_views( $value ) {
211        return (int) rest_sanitize_boolean( $value );
212    }
213
214    /**
215     * Give the Settings tab the roles it lists, with how many users hold each, and the screen that switches the Stats module on and off.
216     *
217     * @param array $data The script data.
218     * @return array The script data with the Stats settings context added.
219     */
220    public static function add_script_data( $data ) {
221        if ( ! current_user_can( 'manage_options' ) ) {
222            return $data;
223        }
224
225        if ( ! function_exists( 'get_editable_roles' ) ) {
226            require_once ABSPATH . 'wp-admin/includes/user.php';
227        }
228
229        // Counting users is a slow query on large sites, so their roles come without counts.
230        $counts = wp_is_large_user_count() ? null : count_users()['avail_roles'];
231        $roles  = array();
232        foreach ( get_editable_roles() as $slug => $role ) {
233            $roles[] = array(
234                'slug'  => $slug,
235                'name'  => translate_user_role( $role['name'] ),
236                'count' => null === $counts ? null : (int) ( $counts[ $slug ] ?? 0 ),
237            );
238        }
239
240        if ( ! isset( $data['premium_analytics'] ) || ! is_array( $data['premium_analytics'] ) ) {
241            $data['premium_analytics'] = array();
242        }
243
244        $data['premium_analytics']['stats_settings'] = array(
245            'roles'        => $roles,
246            // Jetpack can be active without My Jetpack's screen, for example on Atomic, where wpcomsh skips it.
247            'features_url' => class_exists( 'Jetpack' ) && '' !== menu_page_url( 'my-jetpack', false ) ? admin_url( 'admin.php?page=my-jetpack#/features?search=stats' ) : null,
248        );
249
250        return $data;
251    }
252}