Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.59% covered (success)
91.59%
98 / 107
86.67% covered (warning)
86.67%
13 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Admin_Bar
91.59% covered (success)
91.59%
98 / 107
86.67% covered (warning)
86.67%
13 / 15
39.91
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 can_see_chart
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 maybe_add_chart_node
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 maybe_add_chart
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 add_chart_node
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 add_site_menu_link
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 get_dashboard_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_chart_src
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 serve_chart
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 maybe_serve_chart
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 send_chart
12.50% covered (danger)
12.50%
1 / 8
0.00% covered (danger)
0.00%
0 / 1
4.68
 fetch_chart
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
5
 ignore_db_version
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 get_requested_chart
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 get_chart_param
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Stats in the WordPress admin bar.
4 *
5 * @package automattic/jetpack-stats-admin
6 */
7
8namespace Automattic\Jetpack\Stats_Admin;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Constants;
12use Automattic\Jetpack\Stats\Options as Stats_Options;
13use Automattic\Jetpack\Status\Host;
14
15/**
16 * Adds the views chart and a Stats link to the admin bar.
17 */
18class Admin_Bar {
19    /**
20     * The admin bar charts WordPress.com draws, at 1x and 2x.
21     *
22     * @var string[]
23     */
24    const CHARTS = array( 'admin-bar-hours-scale', 'admin-bar-hours-scale-2x' );
25
26    /**
27     * The admin-post.php action that serves a chart.
28     *
29     * @var string
30     */
31    const CHART_ACTION = 'jetpack_stats_admin_bar_chart';
32
33    /**
34     * Register the hooks.
35     *
36     * @return void
37     */
38    public static function init() {
39        add_action( 'admin_bar_menu', array( __CLASS__, 'maybe_add_chart_node' ), 100 );
40        add_action( 'admin_bar_init', array( __CLASS__, 'maybe_add_chart' ) );
41        add_action( 'wp_before_admin_bar_render', array( __CLASS__, 'add_site_menu_link' ) );
42        add_action( 'admin_init', array( __CLASS__, 'maybe_serve_chart' ), 1 );
43        add_action( 'admin_post_' . self::CHART_ACTION, array( __CLASS__, 'serve_chart' ) );
44        add_filter( 'pre_option_db_version', array( __CLASS__, 'ignore_db_version' ) );
45    }
46
47    /**
48     * Whether the current user should see the views chart.
49     *
50     * @return bool
51     */
52    private static function can_see_chart() {
53        return is_user_logged_in() && Stats_Options::get_option( 'admin_bar' ) && current_user_can( 'view_stats' );
54    }
55
56    /**
57     * Add the views chart when the current user should see it.
58     *
59     * Hooked to admin_bar_menu because a REST request fires no page head, and the admin-bar endpoint needs this node.
60     *
61     * @param \WP_Admin_Bar $wp_admin_bar The admin bar.
62     * @return void
63     */
64    public static function maybe_add_chart_node( $wp_admin_bar ) {
65        if ( self::can_see_chart() ) {
66            self::add_chart_node( $wp_admin_bar );
67        }
68    }
69
70    /**
71     * Add the views chart styles to the admin bar stylesheet when the current user should see the chart.
72     *
73     * @return void
74     */
75    public static function maybe_add_chart() {
76        if ( ! self::can_see_chart() ) {
77            return;
78        }
79
80        wp_add_inline_style(
81            'admin-bar',
82            '#wpadminbar .quicklinks li#wp-admin-bar-stats { height: 32px; }
83#wpadminbar .quicklinks li#wp-admin-bar-stats a { height: 32px; padding: 0; }
84#wpadminbar .quicklinks li#wp-admin-bar-stats a div { height: 32px; width: 95px; overflow: hidden; margin: 0 10px; }
85#wpadminbar .quicklinks li#wp-admin-bar-stats a:hover div { width: auto; margin: 0 8px 0 10px; }
86#wpadminbar .quicklinks li#wp-admin-bar-stats a img { height: 24px; margin: 4px 0; max-width: none; border: none; }'
87        );
88    }
89
90    /**
91     * Add the views chart, linked to the Stats dashboard.
92     *
93     * @param \WP_Admin_Bar $wp_admin_bar The admin bar.
94     * @return void
95     */
96    public static function add_chart_node( $wp_admin_bar ) {
97        $img_src    = esc_attr( self::get_chart_src( 'admin-bar-hours-scale' ) );
98        $img_src_2x = esc_attr( self::get_chart_src( 'admin-bar-hours-scale-2x' ) );
99        $alt        = esc_attr__( 'Stats', 'jetpack-stats-admin' );
100        $title      = esc_attr__( 'Views over 48 hours. Click for more Jetpack Stats.', 'jetpack-stats-admin' );
101
102        $wp_admin_bar->add_menu(
103            array(
104                'id'    => 'stats',
105                'href'  => self::get_dashboard_url(),
106                'title' => "<div><img fetchpriority='low' loading='lazy' decoding='async' src='$img_src' srcset='$img_src 1x, $img_src_2x 2x' width='112' height='24' alt='$alt' title='$title'></div>",
107                // A client that reads the admin bar as data, like the omnibar, shows this instead of the image markup.
108                'meta'  => array( 'menu_title' => __( 'Stats', 'jetpack-stats-admin' ) ),
109            )
110        );
111    }
112
113    /**
114     * Add a Stats link to the site-name menu, next to Dashboard.
115     *
116     * @return void
117     */
118    public static function add_site_menu_link() {
119        global $wp_admin_bar;
120
121        // WordPress.com adds its own Stats link to this menu.
122        if (
123            ! is_object( $wp_admin_bar ) ||
124            ! $wp_admin_bar->get_node( 'dashboard' ) ||
125            ! current_user_can( 'view_stats' ) ||
126            ( new Host() )->is_wpcom_platform()
127        ) {
128            return;
129        }
130
131        $wp_admin_bar->add_node(
132            array(
133                'parent' => 'site-name',
134                'id'     => 'jetpack-stats',
135                'title'  => __( 'Stats', 'jetpack-stats-admin' ),
136                'href'   => self::get_dashboard_url(),
137            )
138        );
139    }
140
141    /**
142     * Where the admin bar's Stats links go.
143     *
144     * @return string
145     */
146    private static function get_dashboard_url() {
147        /**
148         * Filters a link to a Stats page, so a newer analytics dashboard can claim it.
149         *
150         * `$args['view']` names the page the link opens: `dashboard`, or `post` with the post in `$args['id']`.
151         * Return `$url` unchanged for a view the dashboard has no page for.
152         *
153         * @since 0.39.0
154         *
155         * @param string $url  The Stats URL.
156         * @param array  $args The page the link opens.
157         */
158        return apply_filters( 'jetpack_stats_url', admin_url( 'admin.php?page=stats' ), array( 'view' => 'dashboard' ) );
159    }
160
161    /**
162     * Get the local URL that serves a chart image.
163     *
164     * Served by admin-post.php, because admin.php returns 403 for a page slug no plugin registers.
165     *
166     * @param string $chart One of the CHARTS.
167     * @return string
168     */
169    public static function get_chart_src( $chart ) {
170        return add_query_arg(
171            array(
172                'action' => self::CHART_ACTION,
173                'chart'  => $chart,
174            ),
175            admin_url( 'admin-post.php' )
176        );
177    }
178
179    /**
180     * Serve the chart an admin-post.php request asks for, or end it with an error status.
181     *
182     * @since $$next-version$$
183     *
184     * @return void
185     */
186    public static function serve_chart() {
187        $chart = self::get_chart_param();
188        if ( null === $chart ) {
189            wp_die( '', '', 400 );
190            return;
191        }
192
193        if ( ! self::can_see_chart() ) {
194            wp_die( '', '', 403 );
195            return;
196        }
197
198        self::send_chart( $chart );
199    }
200
201    /**
202     * Serve a chart and stop, when a legacy `admin.php?page=stats` request asks for one.
203     *
204     * @return void
205     */
206    public static function maybe_serve_chart() {
207        // URLs with `proxy` come from the Jetpack plugin's deprecated stats_get_image_chart_src(), and its own handler forwards their extra parameters.
208        if ( isset( $_GET['proxy'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- An image request carries no nonce, and it changes nothing.
209            return;
210        }
211
212        $chart = self::get_requested_chart();
213        if ( null === $chart || ! self::can_see_chart() ) {
214            return;
215        }
216
217        self::send_chart( $chart );
218    }
219
220    /**
221     * Send a chart image fetched from WordPress.com, and stop.
222     *
223     * The browser cannot fetch the chart from WordPress.com directly, because only the blog token can read it.
224     *
225     * @param string $chart One of the CHARTS.
226     * @return never
227     */
228    private static function send_chart( $chart ) {
229        $image = self::fetch_chart( $chart );
230        if ( null === $image ) {
231            status_header( 502 );
232            exit( 0 );
233        }
234
235        header( 'Content-Type: ' . $image['type'] );
236        header( 'Content-Length: ' . strlen( $image['body'] ) );
237        echo $image['body']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Binary image data.
238        exit( 0 );
239    }
240
241    /**
242     * Fetch a chart image from WordPress.com with the blog token.
243     *
244     * @param string $chart One of the CHARTS.
245     * @return array{type: string, body: string}|null The image, or null when WordPress.com did not return one.
246     */
247    public static function fetch_chart( $chart ) {
248        $server = Constants::get_constant( 'STATS_DASHBOARD_SERVER' ) ?? 'dashboard.wordpress.com';
249
250        // WordPress.com accepts the blog token on this file only when `page`, `proxy` and `blog` are all present.
251        $url = add_query_arg(
252            array(
253                'page'  => 'stats',
254                'proxy' => '',
255                'blog'  => Stats_Options::get_option( 'blog_id' ),
256            ),
257            "https://{$server}/wp-includes/charts/{$chart}.php"
258        );
259
260        $response = Client::remote_request(
261            array(
262                'url'     => $url,
263                'method'  => 'GET',
264                'timeout' => 90,
265                'user_id' => 0,
266            )
267        );
268
269        // A failed request has the code '', which PHP 8 cannot divide.
270        $code = (int) wp_remote_retrieve_response_code( $response );
271        $type = wp_remote_retrieve_header( $response, 'content-type' );
272        $body = wp_remote_retrieve_body( $response );
273        if ( 2 !== (int) ( $code / 100 ) || ! is_string( $type ) || ! str_starts_with( $type, 'image/' ) || '' === $body ) {
274            return null;
275        }
276
277        return array(
278            'type' => $type,
279            'body' => $body,
280        );
281    }
282
283    /**
284     * Keep chart requests from redirecting to upgrade.php while a database upgrade is pending.
285     *
286     * @see wp-admin/admin.php, which compares the stored `db_version` with `$wp_db_version`.
287     *
288     * @param mixed $version The stored database version.
289     * @return mixed
290     */
291    public static function ignore_db_version( $version ) {
292        if ( is_admin() && null !== self::get_requested_chart() ) {
293            global $wp_db_version;
294            return $wp_db_version;
295        }
296
297        return $version;
298    }
299
300    /**
301     * Get the chart a legacy `page=stats` request asks for.
302     *
303     * @return string|null One of the CHARTS, or null when the request is not a `page=stats` chart request.
304     */
305    private static function get_requested_chart() {
306        if ( ! isset( $_GET['page'] ) || 'stats' !== $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- An image request carries no nonce, and it changes nothing.
307            return null;
308        }
309
310        return self::get_chart_param();
311    }
312
313    /**
314     * Get the chart in the request's `chart` parameter.
315     *
316     * @return string|null One of the CHARTS, or null when the parameter names none.
317     */
318    private static function get_chart_param() {
319        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- An image request carries no nonce, and it changes nothing.
320        if ( ! isset( $_GET['chart'] ) ) {
321            return null;
322        }
323
324        $chart = sanitize_key( wp_unslash( $_GET['chart'] ) );
325        // phpcs:enable WordPress.Security.NonceVerification.Recommended
326
327        return in_array( $chart, self::CHARTS, true ) ? $chart : null;
328    }
329}