Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.38% covered (success)
95.38%
186 / 195
89.47% covered (warning)
89.47%
17 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Admin_Bar
95.38% covered (success)
95.38%
186 / 195
89.47% covered (warning)
89.47%
17 / 19
45
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
14 / 14
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
 use_seven_day_chart
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 add_chart_node
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
2
 get_seven_day_panel_html
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 get_seven_day_traffic_url
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_seven_day_chart
100.00% covered (success)
100.00%
27 / 27
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\Assets;
11use Automattic\Jetpack\Connection\Client;
12use Automattic\Jetpack\Constants;
13use Automattic\Jetpack\Feature_Flags\Feature_Flags;
14use Automattic\Jetpack\Stats\Options as Stats_Options;
15use Automattic\Jetpack\Status\Host;
16
17/**
18 * Adds the views chart and a Stats link to the admin bar.
19 */
20class Admin_Bar {
21    /**
22     * The admin bar charts WordPress.com draws, at 1x and 2x.
23     *
24     * @var string[]
25     */
26    const CHARTS = array( 'admin-bar-hours-scale', 'admin-bar-hours-scale-2x' );
27
28    /**
29     * The admin-post.php action that serves a chart.
30     *
31     * @var string
32     */
33    const CHART_ACTION = 'jetpack_stats_admin_bar_chart';
34
35    /**
36     * The handle of the 7-day chart script and style.
37     *
38     * @var string
39     */
40    const SCRIPT_HANDLE = 'jetpack-stats-admin-bar';
41
42    /**
43     * The feature flag that swaps the 48-hour image for the 7-day chart.
44     *
45     * @var string
46     */
47    const SEVEN_DAY_CHART_FLAG = 'jetpack-stats-admin-bar-seven-day-chart';
48
49    /**
50     * How many days the chart shows, ending today.
51     *
52     * @var int
53     */
54    const SEVEN_DAY_CHART_DAYS = 7;
55
56    /**
57     * Register the hooks.
58     *
59     * @return void
60     */
61    public static function init() {
62        Feature_Flags::register(
63            self::SEVEN_DAY_CHART_FLAG,
64            array(
65                'default'     => false,
66                'description' => 'Show a 7-day views chart in the admin bar, with views and visitors on hover, instead of the 48-hour image.',
67                'owner'       => 'jetpack-stats',
68            )
69        );
70
71        add_action( 'admin_bar_menu', array( __CLASS__, 'maybe_add_chart_node' ), 100 );
72        add_action( 'admin_bar_init', array( __CLASS__, 'maybe_add_chart' ) );
73        add_action( 'wp_before_admin_bar_render', array( __CLASS__, 'add_site_menu_link' ) );
74        add_action( 'admin_init', array( __CLASS__, 'maybe_serve_chart' ), 1 );
75        add_action( 'admin_post_' . self::CHART_ACTION, array( __CLASS__, 'serve_chart' ) );
76        add_filter( 'pre_option_db_version', array( __CLASS__, 'ignore_db_version' ) );
77    }
78
79    /**
80     * Whether the current user should see the views chart.
81     *
82     * @return bool
83     */
84    private static function can_see_chart() {
85        return is_user_logged_in() && Stats_Options::get_option( 'admin_bar' ) && current_user_can( 'view_stats' );
86    }
87
88    /**
89     * Whether the admin bar shows the 7-day chart instead of the 48-hour image.
90     *
91     * @return bool
92     */
93    private static function use_seven_day_chart() {
94        return Feature_Flags::is_enabled( self::SEVEN_DAY_CHART_FLAG );
95    }
96
97    /**
98     * Add the views chart when the current user should see it.
99     *
100     * Hooked to admin_bar_menu because a REST request fires no page head, and the admin-bar endpoint needs this node.
101     *
102     * @param \WP_Admin_Bar $wp_admin_bar The admin bar.
103     * @return void
104     */
105    public static function maybe_add_chart_node( $wp_admin_bar ) {
106        if ( self::can_see_chart() ) {
107            self::add_chart_node( $wp_admin_bar );
108        }
109    }
110
111    /**
112     * Add the views chart styles to the admin bar stylesheet when the current user should see the chart.
113     *
114     * @return void
115     */
116    public static function maybe_add_chart() {
117        if ( ! self::can_see_chart() ) {
118            return;
119        }
120
121        if ( self::use_seven_day_chart() ) {
122            self::enqueue_seven_day_chart();
123            return;
124        }
125
126        wp_add_inline_style(
127            'admin-bar',
128            '#wpadminbar .quicklinks li#wp-admin-bar-stats { height: 32px; }
129#wpadminbar .quicklinks li#wp-admin-bar-stats a { height: 32px; padding: 0; }
130#wpadminbar .quicklinks li#wp-admin-bar-stats a div { height: 32px; width: 95px; overflow: hidden; margin: 0 10px; }
131#wpadminbar .quicklinks li#wp-admin-bar-stats a:hover div { width: auto; margin: 0 8px 0 10px; }
132#wpadminbar .quicklinks li#wp-admin-bar-stats a img { height: 24px; margin: 4px 0; max-width: none; border: none; }'
133        );
134    }
135
136    /**
137     * Add the views chart, linked to the Stats dashboard.
138     *
139     * @param \WP_Admin_Bar $wp_admin_bar The admin bar.
140     * @return void
141     */
142    public static function add_chart_node( $wp_admin_bar ) {
143        if ( self::use_seven_day_chart() ) {
144            $wp_admin_bar->add_menu(
145                array(
146                    'id'    => 'stats',
147                    'href'  => self::get_dashboard_url(),
148                    'title' => '<span id="jetpack-stats-admin-bar-chart" class="jp-stats-admin-bar-chart" aria-hidden="true"></span><span class="jp-stats-admin-bar-label">' . esc_html__( 'Stats', 'jetpack-stats-admin' ) . '</span>',
149                    'meta'  => array(
150                        'menu_title' => __( 'Stats', 'jetpack-stats-admin' ),
151                        'class'      => 'menupop',
152                        'html'       => self::get_seven_day_panel_html(),
153                    ),
154                )
155            );
156            return;
157        }
158
159        $img_src    = esc_attr( self::get_chart_src( 'admin-bar-hours-scale' ) );
160        $img_src_2x = esc_attr( self::get_chart_src( 'admin-bar-hours-scale-2x' ) );
161        $alt        = esc_attr__( 'Stats', 'jetpack-stats-admin' );
162        $title      = esc_attr__( 'Views over 48 hours. Click for more Jetpack Stats.', 'jetpack-stats-admin' );
163
164        $wp_admin_bar->add_menu(
165            array(
166                'id'    => 'stats',
167                'href'  => self::get_dashboard_url(),
168                '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>",
169                // A client that reads the admin bar as data, like the omnibar, shows this instead of the image markup.
170                'meta'  => array( 'menu_title' => __( 'Stats', 'jetpack-stats-admin' ) ),
171            )
172        );
173    }
174
175    /**
176     * The hover panel of the 7-day chart. The script fills in the bars and totals once stats load.
177     *
178     * @return string
179     */
180    private static function get_seven_day_panel_html() {
181        $metric = static function ( $key, $label ) {
182            return sprintf( '<div class="jp-stats-admin-bar-panel__metric"><dt>%s</dt><dd data-metric="%s">–</dd></div>', esc_html( $label ), esc_attr( $key ) );
183        };
184
185        return '<div class="ab-sub-wrapper"><div id="jetpack-stats-admin-bar-panel" class="jp-stats-admin-bar-panel">'
186            . '<div class="jp-stats-admin-bar-panel__title">' . esc_html(
187                sprintf(
188                    /* translators: %d: Number of days. */
189                    _n( 'Last %d day', 'Last %d days', self::SEVEN_DAY_CHART_DAYS, 'jetpack-stats-admin' ),
190                    self::SEVEN_DAY_CHART_DAYS
191                )
192            ) . '</div>'
193            . '<span class="ab-label jp-stats-admin-bar-panel__bars" aria-hidden="true"></span>'
194            . '<dl class="jp-stats-admin-bar-panel__metrics">'
195            . $metric( 'views', __( 'Views', 'jetpack-stats-admin' ) )
196            . $metric( 'visitors', __( 'Visitors', 'jetpack-stats-admin' ) )
197            . '</dl>'
198            . '<p class="jp-stats-admin-bar-panel__error" hidden>' . esc_html__( 'Stats are unavailable right now.', 'jetpack-stats-admin' ) . '</p>'
199            . '</div>'
200            . '<ul class="ab-submenu jp-stats-admin-bar-panel__footer"><li><a class="ab-item" href="' . esc_url( self::get_seven_day_traffic_url() ) . '">' . esc_html__( 'View all stats', 'jetpack-stats-admin' ) . '</a></li></ul>'
201            . '</div>';
202    }
203
204    /**
205     * The Traffic page for the days the chart shows, ending today in the site's timezone.
206     *
207     * @return string
208     */
209    private static function get_seven_day_traffic_url() {
210        $today = new \DateTimeImmutable( 'now', wp_timezone() );
211        $start = $today->modify( '-' . ( self::SEVEN_DAY_CHART_DAYS - 1 ) . ' days' )->format( 'Y-m-d' );
212        $end   = $today->format( 'Y-m-d' );
213        $url   = admin_url( 'admin.php?page=stats#!/stats/day/' . (int) \Jetpack_Options::get_option( 'id' ) . "?chartStart=$start&chartEnd=$end" );
214
215        /** This filter is documented in src/class-admin-bar.php */
216        return apply_filters(
217            'jetpack_stats_url',
218            $url,
219            array(
220                'view'    => 'dashboard',
221                'section' => 'traffic',
222                'range'   => array(
223                    'from' => $start,
224                    'to'   => $end,
225                ),
226            )
227        );
228    }
229
230    /**
231     * Enqueue the script that draws the 7-day chart and fills the hover panel.
232     *
233     * @return void
234     */
235    private static function enqueue_seven_day_chart() {
236        Assets::register_script(
237            self::SCRIPT_HANDLE,
238            '../build/admin-bar.js',
239            __FILE__,
240            array(
241                'in_footer' => true,
242                'css_path'  => '../build/admin-bar.css',
243                'enqueue'   => true,
244            )
245        );
246
247        $config = array(
248            'restUrl' => add_query_arg(
249                array(
250                    'unit'        => 'day',
251                    'quantity'    => self::SEVEN_DAY_CHART_DAYS,
252                    'stat_fields' => 'views,visitors',
253                ),
254                rest_url( 'jetpack/v4/stats-app/sites/' . (int) \Jetpack_Options::get_option( 'id' ) . '/stats/visits' )
255            ),
256            'nonce'   => wp_create_nonce( 'wp_rest' ),
257            'days'    => self::SEVEN_DAY_CHART_DAYS,
258        );
259
260        wp_add_inline_script(
261            self::SCRIPT_HANDLE,
262            'window.jetpackStatsAdminBar = ' . wp_json_encode( $config, JSON_HEX_TAG | JSON_HEX_AMP ) . ';',
263            'before'
264        );
265    }
266
267    /**
268     * Add a Stats link to the site-name menu, next to Dashboard.
269     *
270     * @return void
271     */
272    public static function add_site_menu_link() {
273        global $wp_admin_bar;
274
275        // WordPress.com adds its own Stats link to this menu.
276        if (
277            ! is_object( $wp_admin_bar ) ||
278            ! $wp_admin_bar->get_node( 'dashboard' ) ||
279            ! current_user_can( 'view_stats' ) ||
280            ( new Host() )->is_wpcom_platform()
281        ) {
282            return;
283        }
284
285        $wp_admin_bar->add_node(
286            array(
287                'parent' => 'site-name',
288                'id'     => 'jetpack-stats',
289                'title'  => __( 'Stats', 'jetpack-stats-admin' ),
290                'href'   => self::get_dashboard_url(),
291            )
292        );
293    }
294
295    /**
296     * Where the admin bar's Stats links go.
297     *
298     * @return string
299     */
300    private static function get_dashboard_url() {
301        /**
302         * Filters a link to a Stats page, so a newer analytics dashboard can claim it.
303         *
304         * `$args['view']` names the page the link opens: `dashboard`, or `post` with the post in `$args['id']`.
305         * A `dashboard` link may carry a `section` and a `range` of `from`/`to` days as `Y-m-d`.
306         * Return `$url` unchanged for a view the dashboard has no page for.
307         *
308         * @since 0.39.0
309         * @since $$next-version$$ The `dashboard` view may carry a `section` and a `range`.
310         *
311         * @param string $url  The Stats URL.
312         * @param array  $args The page the link opens.
313         */
314        return apply_filters( 'jetpack_stats_url', admin_url( 'admin.php?page=stats' ), array( 'view' => 'dashboard' ) );
315    }
316
317    /**
318     * Get the local URL that serves a chart image.
319     *
320     * Served by admin-post.php, because admin.php returns 403 for a page slug no plugin registers.
321     *
322     * @param string $chart One of the CHARTS.
323     * @return string
324     */
325    public static function get_chart_src( $chart ) {
326        return add_query_arg(
327            array(
328                'action' => self::CHART_ACTION,
329                'chart'  => $chart,
330            ),
331            admin_url( 'admin-post.php' )
332        );
333    }
334
335    /**
336     * Serve the chart an admin-post.php request asks for, or end it with an error status.
337     *
338     * @since 0.39.1
339     *
340     * @return void
341     */
342    public static function serve_chart() {
343        $chart = self::get_chart_param();
344        if ( null === $chart ) {
345            wp_die( '', '', 400 );
346            return;
347        }
348
349        if ( ! self::can_see_chart() ) {
350            wp_die( '', '', 403 );
351            return;
352        }
353
354        self::send_chart( $chart );
355    }
356
357    /**
358     * Serve a chart and stop, when a legacy `admin.php?page=stats` request asks for one.
359     *
360     * @return void
361     */
362    public static function maybe_serve_chart() {
363        // URLs with `proxy` come from the Jetpack plugin's deprecated stats_get_image_chart_src(), and its own handler forwards their extra parameters.
364        if ( isset( $_GET['proxy'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- An image request carries no nonce, and it changes nothing.
365            return;
366        }
367
368        $chart = self::get_requested_chart();
369        if ( null === $chart || ! self::can_see_chart() ) {
370            return;
371        }
372
373        self::send_chart( $chart );
374    }
375
376    /**
377     * Send a chart image fetched from WordPress.com, and stop.
378     *
379     * The browser cannot fetch the chart from WordPress.com directly, because only the blog token can read it.
380     *
381     * @param string $chart One of the CHARTS.
382     * @return never
383     */
384    private static function send_chart( $chart ) {
385        $image = self::fetch_chart( $chart );
386        if ( null === $image ) {
387            status_header( 502 );
388            exit( 0 );
389        }
390
391        header( 'Content-Type: ' . $image['type'] );
392        header( 'Content-Length: ' . strlen( $image['body'] ) );
393        echo $image['body']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Binary image data.
394        exit( 0 );
395    }
396
397    /**
398     * Fetch a chart image from WordPress.com with the blog token.
399     *
400     * @param string $chart One of the CHARTS.
401     * @return array{type: string, body: string}|null The image, or null when WordPress.com did not return one.
402     */
403    public static function fetch_chart( $chart ) {
404        $server = Constants::get_constant( 'STATS_DASHBOARD_SERVER' ) ?? 'dashboard.wordpress.com';
405
406        // WordPress.com accepts the blog token on this file only when `page`, `proxy` and `blog` are all present.
407        $url = add_query_arg(
408            array(
409                'page'  => 'stats',
410                'proxy' => '',
411                'blog'  => Stats_Options::get_option( 'blog_id' ),
412            ),
413            "https://{$server}/wp-includes/charts/{$chart}.php"
414        );
415
416        $response = Client::remote_request(
417            array(
418                'url'     => $url,
419                'method'  => 'GET',
420                'timeout' => 90,
421                'user_id' => 0,
422            )
423        );
424
425        // A failed request has the code '', which PHP 8 cannot divide.
426        $code = (int) wp_remote_retrieve_response_code( $response );
427        $type = wp_remote_retrieve_header( $response, 'content-type' );
428        $body = wp_remote_retrieve_body( $response );
429        if ( 2 !== (int) ( $code / 100 ) || ! is_string( $type ) || ! str_starts_with( $type, 'image/' ) || '' === $body ) {
430            return null;
431        }
432
433        return array(
434            'type' => $type,
435            'body' => $body,
436        );
437    }
438
439    /**
440     * Keep chart requests from redirecting to upgrade.php while a database upgrade is pending.
441     *
442     * @see wp-admin/admin.php, which compares the stored `db_version` with `$wp_db_version`.
443     *
444     * @param mixed $version The stored database version.
445     * @return mixed
446     */
447    public static function ignore_db_version( $version ) {
448        if ( is_admin() && null !== self::get_requested_chart() ) {
449            global $wp_db_version;
450            return $wp_db_version;
451        }
452
453        return $version;
454    }
455
456    /**
457     * Get the chart a legacy `page=stats` request asks for.
458     *
459     * @return string|null One of the CHARTS, or null when the request is not a `page=stats` chart request.
460     */
461    private static function get_requested_chart() {
462        if ( ! isset( $_GET['page'] ) || 'stats' !== $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- An image request carries no nonce, and it changes nothing.
463            return null;
464        }
465
466        return self::get_chart_param();
467    }
468
469    /**
470     * Get the chart in the request's `chart` parameter.
471     *
472     * @return string|null One of the CHARTS, or null when the parameter names none.
473     */
474    private static function get_chart_param() {
475        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- An image request carries no nonce, and it changes nothing.
476        if ( ! isset( $_GET['chart'] ) ) {
477            return null;
478        }
479
480        $chart = sanitize_key( wp_unslash( $_GET['chart'] ) );
481        // phpcs:enable WordPress.Security.NonceVerification.Recommended
482
483        return in_array( $chart, self::CHARTS, true ) ? $chart : null;
484    }
485}