Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.60% covered (warning)
88.60%
101 / 114
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Admin_Bar
88.60% covered (warning)
88.60%
101 / 114
66.67% covered (warning)
66.67%
6 / 9
33.52
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 maybe_add_chart
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
5
 add_chart_node
100.00% covered (success)
100.00%
11 / 11
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_chart_src
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 maybe_serve_chart
46.15% covered (danger)
46.15%
6 / 13
0.00% covered (danger)
0.00%
0 / 1
11.62
 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
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
5.39
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     * Register the hooks.
28     *
29     * @return void
30     */
31    public static function init() {
32        add_action( 'admin_head', array( __CLASS__, 'maybe_add_chart' ), 100 );
33        add_action( 'wp_head', array( __CLASS__, 'maybe_add_chart' ), 100 );
34        add_action( 'wp_before_admin_bar_render', array( __CLASS__, 'add_site_menu_link' ) );
35        add_action( 'admin_init', array( __CLASS__, 'maybe_serve_chart' ), 1 );
36        add_filter( 'pre_option_db_version', array( __CLASS__, 'ignore_db_version' ) );
37    }
38
39    /**
40     * Queue the views chart and print its styles when the current user should see it.
41     *
42     * @return void
43     */
44    public static function maybe_add_chart() {
45        if (
46            ! is_user_logged_in() ||
47            ! Stats_Options::get_option( 'admin_bar' ) ||
48            ! current_user_can( 'view_stats' ) ||
49            ! is_admin_bar_showing()
50        ) {
51            return;
52        }
53
54        add_action( 'admin_bar_menu', array( __CLASS__, 'add_chart_node' ), 100 );
55        ?>
56<style data-ampdevmode type='text/css'>
57#wpadminbar .quicklinks li#wp-admin-bar-stats {
58    height: 32px;
59}
60#wpadminbar .quicklinks li#wp-admin-bar-stats a {
61    height: 32px;
62    padding: 0;
63}
64#wpadminbar .quicklinks li#wp-admin-bar-stats a div {
65    height: 32px;
66    width: 95px;
67    overflow: hidden;
68    margin: 0 10px;
69}
70#wpadminbar .quicklinks li#wp-admin-bar-stats a:hover div {
71    width: auto;
72    margin: 0 8px 0 10px;
73}
74#wpadminbar .quicklinks li#wp-admin-bar-stats a img {
75    height: 24px;
76    margin: 4px 0;
77    max-width: none;
78    border: none;
79}
80</style>
81        <?php
82    }
83
84    /**
85     * Add the views chart, linked to the Stats dashboard.
86     *
87     * @param \WP_Admin_Bar $wp_admin_bar The admin bar.
88     * @return void
89     */
90    public static function add_chart_node( $wp_admin_bar ) {
91        $img_src    = esc_attr( self::get_chart_src( 'admin-bar-hours-scale' ) );
92        $img_src_2x = esc_attr( self::get_chart_src( 'admin-bar-hours-scale-2x' ) );
93        $alt        = esc_attr__( 'Stats', 'jetpack-stats-admin' );
94        $title      = esc_attr__( 'Views over 48 hours. Click for more Jetpack Stats.', 'jetpack-stats-admin' );
95
96        $wp_admin_bar->add_menu(
97            array(
98                'id'    => 'stats',
99                'href'  => admin_url( 'admin.php?page=stats' ),
100                '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>",
101            )
102        );
103    }
104
105    /**
106     * Add a Stats link to the site-name menu, next to Dashboard.
107     *
108     * @return void
109     */
110    public static function add_site_menu_link() {
111        global $wp_admin_bar;
112
113        // WordPress.com adds its own Stats link to this menu.
114        if (
115            ! is_object( $wp_admin_bar ) ||
116            ! $wp_admin_bar->get_node( 'dashboard' ) ||
117            ! current_user_can( 'view_stats' ) ||
118            ( new Host() )->is_wpcom_platform()
119        ) {
120            return;
121        }
122
123        $wp_admin_bar->add_node(
124            array(
125                'parent' => 'site-name',
126                'id'     => 'jetpack-stats',
127                'title'  => __( 'Stats', 'jetpack-stats-admin' ),
128                'href'   => admin_url( 'admin.php?page=stats' ),
129            )
130        );
131    }
132
133    /**
134     * Get the local URL that serves a chart image.
135     *
136     * @param string $chart One of the CHARTS.
137     * @return string
138     */
139    public static function get_chart_src( $chart ) {
140        return add_query_arg(
141            array(
142                'page'  => 'stats',
143                'chart' => $chart,
144            ),
145            admin_url( 'admin.php' )
146        );
147    }
148
149    /**
150     * Serve a chart image and stop, when the request asks for one.
151     *
152     * The browser cannot fetch the chart from WordPress.com directly, because only the blog token can read it.
153     *
154     * @return void
155     */
156    public static function maybe_serve_chart() {
157        // URLs with `proxy` come from the Jetpack plugin's deprecated stats_get_image_chart_src(), and its own handler forwards their extra parameters.
158        if ( isset( $_GET['proxy'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- An image request carries no nonce, and it changes nothing.
159            return;
160        }
161
162        $chart = self::get_requested_chart();
163        if ( null === $chart || ! Stats_Options::get_option( 'admin_bar' ) || ! current_user_can( 'view_stats' ) ) {
164            return;
165        }
166
167        $image = self::fetch_chart( $chart );
168        if ( null === $image ) {
169            status_header( 502 );
170            exit( 0 );
171        }
172
173        header( 'Content-Type: ' . $image['type'] );
174        header( 'Content-Length: ' . strlen( $image['body'] ) );
175        echo $image['body']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Binary image data.
176        exit( 0 );
177    }
178
179    /**
180     * Fetch a chart image from WordPress.com with the blog token.
181     *
182     * @param string $chart One of the CHARTS.
183     * @return array{type: string, body: string}|null The image, or null when WordPress.com did not return one.
184     */
185    public static function fetch_chart( $chart ) {
186        $server = Constants::get_constant( 'STATS_DASHBOARD_SERVER' ) ?? 'dashboard.wordpress.com';
187
188        // WordPress.com accepts the blog token on this file only when `page`, `proxy` and `blog` are all present.
189        $url = add_query_arg(
190            array(
191                'page'  => 'stats',
192                'proxy' => '',
193                'blog'  => Stats_Options::get_option( 'blog_id' ),
194            ),
195            "https://{$server}/wp-includes/charts/{$chart}.php"
196        );
197
198        $response = Client::remote_request(
199            array(
200                'url'     => $url,
201                'method'  => 'GET',
202                'timeout' => 90,
203                'user_id' => 0,
204            )
205        );
206
207        // A failed request has the code '', which PHP 8 cannot divide.
208        $code = (int) wp_remote_retrieve_response_code( $response );
209        $type = wp_remote_retrieve_header( $response, 'content-type' );
210        $body = wp_remote_retrieve_body( $response );
211        if ( 2 !== (int) ( $code / 100 ) || ! is_string( $type ) || ! str_starts_with( $type, 'image/' ) || '' === $body ) {
212            return null;
213        }
214
215        return array(
216            'type' => $type,
217            'body' => $body,
218        );
219    }
220
221    /**
222     * Keep chart requests from redirecting to upgrade.php while a database upgrade is pending.
223     *
224     * @see wp-admin/admin.php, which compares the stored `db_version` with `$wp_db_version`.
225     *
226     * @param mixed $version The stored database version.
227     * @return mixed
228     */
229    public static function ignore_db_version( $version ) {
230        if ( is_admin() && null !== self::get_requested_chart() ) {
231            global $wp_db_version;
232            return $wp_db_version;
233        }
234
235        return $version;
236    }
237
238    /**
239     * Get the chart the current request asks for.
240     *
241     * @return string|null One of the CHARTS, or null when the request is not for a chart.
242     */
243    private static function get_requested_chart() {
244        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- An image request carries no nonce, and it changes nothing.
245        if ( ! isset( $_GET['page'] ) || ! isset( $_GET['chart'] ) || 'stats' !== $_GET['page'] ) {
246            return null;
247        }
248
249        $chart = sanitize_key( wp_unslash( $_GET['chart'] ) );
250        // phpcs:enable WordPress.Security.NonceVerification.Recommended
251
252        return in_array( $chart, self::CHARTS, true ) ? $chart : null;
253    }
254}