Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.49% covered (success)
91.49%
86 / 94
83.33% covered (warning)
83.33%
10 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Admin_Bar
91.49% covered (success)
91.49%
86 / 94
83.33% covered (warning)
83.33%
10 / 12
35.76
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
5 / 5
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
 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_bar_menu', array( __CLASS__, 'maybe_add_chart_node' ), 100 );
33        add_action( 'admin_bar_init', array( __CLASS__, 'maybe_add_chart' ) );
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     * Whether the current user should see the views chart.
41     *
42     * @return bool
43     */
44    private static function can_see_chart() {
45        return is_user_logged_in() && Stats_Options::get_option( 'admin_bar' ) && current_user_can( 'view_stats' );
46    }
47
48    /**
49     * Add the views chart when the current user should see it.
50     *
51     * Hooked to admin_bar_menu because a REST request fires no page head, and the admin-bar endpoint needs this node.
52     *
53     * @param \WP_Admin_Bar $wp_admin_bar The admin bar.
54     * @return void
55     */
56    public static function maybe_add_chart_node( $wp_admin_bar ) {
57        if ( self::can_see_chart() ) {
58            self::add_chart_node( $wp_admin_bar );
59        }
60    }
61
62    /**
63     * Add the views chart styles to the admin bar stylesheet when the current user should see the chart.
64     *
65     * @return void
66     */
67    public static function maybe_add_chart() {
68        if ( ! self::can_see_chart() ) {
69            return;
70        }
71
72        wp_add_inline_style(
73            'admin-bar',
74            '#wpadminbar .quicklinks li#wp-admin-bar-stats { height: 32px; }
75#wpadminbar .quicklinks li#wp-admin-bar-stats a { height: 32px; padding: 0; }
76#wpadminbar .quicklinks li#wp-admin-bar-stats a div { height: 32px; width: 95px; overflow: hidden; margin: 0 10px; }
77#wpadminbar .quicklinks li#wp-admin-bar-stats a:hover div { width: auto; margin: 0 8px 0 10px; }
78#wpadminbar .quicklinks li#wp-admin-bar-stats a img { height: 24px; margin: 4px 0; max-width: none; border: none; }'
79        );
80    }
81
82    /**
83     * Add the views chart, linked to the Stats dashboard.
84     *
85     * @param \WP_Admin_Bar $wp_admin_bar The admin bar.
86     * @return void
87     */
88    public static function add_chart_node( $wp_admin_bar ) {
89        $img_src    = esc_attr( self::get_chart_src( 'admin-bar-hours-scale' ) );
90        $img_src_2x = esc_attr( self::get_chart_src( 'admin-bar-hours-scale-2x' ) );
91        $alt        = esc_attr__( 'Stats', 'jetpack-stats-admin' );
92        $title      = esc_attr__( 'Views over 48 hours. Click for more Jetpack Stats.', 'jetpack-stats-admin' );
93
94        $wp_admin_bar->add_menu(
95            array(
96                'id'    => 'stats',
97                'href'  => self::get_dashboard_url(),
98                '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>",
99                // A client that reads the admin bar as data, like the omnibar, shows this instead of the image markup.
100                'meta'  => array( 'menu_title' => __( 'Stats', 'jetpack-stats-admin' ) ),
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'   => self::get_dashboard_url(),
129            )
130        );
131    }
132
133    /**
134     * Where the admin bar's Stats links go.
135     *
136     * @return string
137     */
138    private static function get_dashboard_url() {
139        /**
140         * Filters a link to a Stats page, so a newer analytics dashboard can claim it.
141         *
142         * `$args['view']` names the page the link opens: `dashboard`, or `post` with the post in `$args['id']`.
143         * Return `$url` unchanged for a view the dashboard has no page for.
144         *
145         * @since $$next-version$$
146         *
147         * @param string $url  The Stats URL.
148         * @param array  $args The page the link opens.
149         */
150        return apply_filters( 'jetpack_stats_url', admin_url( 'admin.php?page=stats' ), array( 'view' => 'dashboard' ) );
151    }
152
153    /**
154     * Get the local URL that serves a chart image.
155     *
156     * @param string $chart One of the CHARTS.
157     * @return string
158     */
159    public static function get_chart_src( $chart ) {
160        return add_query_arg(
161            array(
162                'page'  => 'stats',
163                'chart' => $chart,
164            ),
165            admin_url( 'admin.php' )
166        );
167    }
168
169    /**
170     * Serve a chart image and stop, when the request asks for one.
171     *
172     * The browser cannot fetch the chart from WordPress.com directly, because only the blog token can read it.
173     *
174     * @return void
175     */
176    public static function maybe_serve_chart() {
177        // URLs with `proxy` come from the Jetpack plugin's deprecated stats_get_image_chart_src(), and its own handler forwards their extra parameters.
178        if ( isset( $_GET['proxy'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- An image request carries no nonce, and it changes nothing.
179            return;
180        }
181
182        $chart = self::get_requested_chart();
183        if ( null === $chart || ! Stats_Options::get_option( 'admin_bar' ) || ! current_user_can( 'view_stats' ) ) {
184            return;
185        }
186
187        $image = self::fetch_chart( $chart );
188        if ( null === $image ) {
189            status_header( 502 );
190            exit( 0 );
191        }
192
193        header( 'Content-Type: ' . $image['type'] );
194        header( 'Content-Length: ' . strlen( $image['body'] ) );
195        echo $image['body']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Binary image data.
196        exit( 0 );
197    }
198
199    /**
200     * Fetch a chart image from WordPress.com with the blog token.
201     *
202     * @param string $chart One of the CHARTS.
203     * @return array{type: string, body: string}|null The image, or null when WordPress.com did not return one.
204     */
205    public static function fetch_chart( $chart ) {
206        $server = Constants::get_constant( 'STATS_DASHBOARD_SERVER' ) ?? 'dashboard.wordpress.com';
207
208        // WordPress.com accepts the blog token on this file only when `page`, `proxy` and `blog` are all present.
209        $url = add_query_arg(
210            array(
211                'page'  => 'stats',
212                'proxy' => '',
213                'blog'  => Stats_Options::get_option( 'blog_id' ),
214            ),
215            "https://{$server}/wp-includes/charts/{$chart}.php"
216        );
217
218        $response = Client::remote_request(
219            array(
220                'url'     => $url,
221                'method'  => 'GET',
222                'timeout' => 90,
223                'user_id' => 0,
224            )
225        );
226
227        // A failed request has the code '', which PHP 8 cannot divide.
228        $code = (int) wp_remote_retrieve_response_code( $response );
229        $type = wp_remote_retrieve_header( $response, 'content-type' );
230        $body = wp_remote_retrieve_body( $response );
231        if ( 2 !== (int) ( $code / 100 ) || ! is_string( $type ) || ! str_starts_with( $type, 'image/' ) || '' === $body ) {
232            return null;
233        }
234
235        return array(
236            'type' => $type,
237            'body' => $body,
238        );
239    }
240
241    /**
242     * Keep chart requests from redirecting to upgrade.php while a database upgrade is pending.
243     *
244     * @see wp-admin/admin.php, which compares the stored `db_version` with `$wp_db_version`.
245     *
246     * @param mixed $version The stored database version.
247     * @return mixed
248     */
249    public static function ignore_db_version( $version ) {
250        if ( is_admin() && null !== self::get_requested_chart() ) {
251            global $wp_db_version;
252            return $wp_db_version;
253        }
254
255        return $version;
256    }
257
258    /**
259     * Get the chart the current request asks for.
260     *
261     * @return string|null One of the CHARTS, or null when the request is not for a chart.
262     */
263    private static function get_requested_chart() {
264        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- An image request carries no nonce, and it changes nothing.
265        if ( ! isset( $_GET['page'] ) || ! isset( $_GET['chart'] ) || 'stats' !== $_GET['page'] ) {
266            return null;
267        }
268
269        $chart = sanitize_key( wp_unslash( $_GET['chart'] ) );
270        // phpcs:enable WordPress.Security.NonceVerification.Recommended
271
272        return in_array( $chart, self::CHARTS, true ) ? $chart : null;
273    }
274}