Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.74% covered (success)
94.74%
72 / 76
90.91% covered (success)
90.91%
10 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Stats_Links
94.74% covered (success)
94.74%
72 / 76
90.91% covered (success)
90.91%
10 / 11
38.21
0.00% covered (danger)
0.00%
0 / 1
 register
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 open_retired_stats_page
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
3.79
 is_retired_stats_page_request
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 render_retired_stats_page
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 legacy_route_path
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 post_path
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 filter_url
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 dashboard_path
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 date_window_params
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
9
 post_url
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 route_url
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Stats entry points outside the dashboard.
4 *
5 * @package automattic/jetpack-premium-analytics
6 */
7
8namespace Automattic\Jetpack\PremiumAnalytics;
9
10/**
11 * Sends links to Stats pages (the admin bar, the post list Views column, the newsletter action bar) to the matching
12 * dashboard page instead, and opens the dashboard for `admin.php?page=stats` once no plugin registers that page.
13 *
14 * @since 0.12.0
15 */
16class Stats_Links {
17
18    /**
19     * Query arg that carries the old `#!/stats/…` route back to the server.
20     */
21    const ROUTE_QUERY_ARG = 'stats_route';
22
23    /**
24     * Views of the old `#!/stats/<view>` routes, and the dashboard section each one opens.
25     */
26    private const LEGACY_SECTIONS = array(
27        'day'         => 'traffic',
28        'week'        => 'traffic',
29        'month'       => 'traffic',
30        'year'        => 'traffic',
31        'insights'    => 'insights',
32        'subscribers' => 'subscribers',
33        'store'       => 'woocommerce',
34        'ads'         => 'ads',
35        'settings'    => 'settings',
36    );
37
38    /**
39     * Claim the links. Idempotent, like the other register() calls.
40     *
41     * Outside the admin-chrome gate on purpose: the admin bar and the action bar also render on the front end, and Quick Edit re-renders the Views column over AJAX.
42     *
43     * @return void
44     */
45    public static function register() {
46        add_filter( 'jetpack_stats_url', array( __CLASS__, 'filter_url' ), 10, 2 );
47        add_action( 'admin_page_access_denied', array( __CLASS__, 'open_retired_stats_page' ) );
48    }
49
50    /**
51     * Open the dashboard in place of `admin.php?page=stats` once no plugin registers that page.
52     *
53     * Core fires this for every denied admin page, a registered one the user may not open included;
54     * the capability check is what keeps that user on core's 403.
55     *
56     * @return void
57     */
58    public static function open_retired_stats_page() {
59        if ( ! self::is_retired_stats_page_request() ) {
60            return;
61        }
62
63        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only picks where to send the reader.
64        if ( isset( $_GET[ self::ROUTE_QUERY_ARG ] ) ) {
65            // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only picks where to send the reader.
66            $route = sanitize_text_field( wp_unslash( $_GET[ self::ROUTE_QUERY_ARG ] ) );
67            wp_safe_redirect( Analytics::dashboard_url( self::legacy_route_path( $route ) ) );
68            exit( 0 );
69        }
70
71        nocache_headers();
72        self::render_retired_stats_page();
73        exit( 0 );
74    }
75
76    /**
77     * Whether this request is for `page=stats` with no classic Stats page to open, by a user who can read Stats here.
78     *
79     * @return bool
80     */
81    private static function is_retired_stats_page_request() {
82        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only picks where to send the reader.
83        $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
84
85        return 'stats' === $page
86            && null === Classic_Stats::page_url()
87            && Capabilities::current_user_can_open_stats();
88    }
89
90    /**
91     * Print a page that hands the old `#!/stats/…` route back to the server, since the fragment never reaches it.
92     *
93     * @return void
94     */
95    private static function render_retired_stats_page() {
96        $script = sprintf(
97            'window.location.replace( window.location.pathname + window.location.search + "&%s=" + encodeURIComponent( window.location.hash ) );',
98            self::ROUTE_QUERY_ARG
99        );
100
101        ?>
102<!DOCTYPE html>
103<html <?php language_attributes(); ?>>
104<head>
105<meta charset="<?php echo esc_attr( get_bloginfo( 'charset' ) ); ?>">
106<title><?php echo esc_html__( 'Stats', 'jetpack-premium-analytics-pkg' ); ?></title>
107<noscript><meta http-equiv="refresh" content="0;url=<?php echo esc_url( Analytics::dashboard_url( '/' ) ); ?>"></noscript>
108        <?php wp_print_inline_script_tag( $script ); ?>
109</head>
110<body></body>
111</html>
112        <?php
113    }
114
115    /**
116     * The dashboard route for an old `#!/stats/…` route, or its root when it has no counterpart.
117     *
118     * @param string $route The old route, e.g. `#!/stats/post/123/9`.
119     * @return string
120     */
121    private static function legacy_route_path( $route ) {
122        if ( ! preg_match( '~^#!/stats/([^?]*)~', $route, $match ) ) {
123            return '/';
124        }
125
126        $segments = explode( '/', $match[1] );
127        $view     = $segments[0];
128
129        if ( 'post' === $view ) {
130            return self::post_path( $segments[1] ?? 0 );
131        }
132
133        // The email routes put the post ID after the metric and the period.
134        if ( 'email' === $view && in_array( $segments[1] ?? '', array( 'opens', 'clicks' ), true ) ) {
135            return self::post_path( $segments[3] ?? 0, 'email-' . $segments[1] );
136        }
137
138        return array_key_exists( $view, self::LEGACY_SECTIONS ) ? '/?section=' . self::LEGACY_SECTIONS[ $view ] : '/';
139    }
140
141    /**
142     * A post detail route, or the dashboard root when there is no real post.
143     *
144     * @param mixed  $post_id The post.
145     * @param string $section The post detail tab, or '' for its default.
146     * @return string
147     */
148    private static function post_path( $post_id, $section = '' ) {
149        $post_id = (int) $post_id;
150
151        if ( $post_id <= 0 ) {
152            return '/';
153        }
154
155        return '/post/' . $post_id . ( '' === $section ? '' : '?section=' . $section );
156    }
157
158    /**
159     * Point a Stats link at the dashboard page for the view it opens.
160     *
161     * @param string $url  Stats URL.
162     * @param array  $args The page the link opens: `view`, plus `id` for the `post` view, or `section` and a
163     *                     `range` of `from`/`to` days as `Y-m-d` for the `dashboard` view.
164     * @return string
165     */
166    public static function filter_url( $url, $args ) {
167        $view = is_array( $args ) ? ( $args['view'] ?? null ) : null;
168
169        if ( 'dashboard' === $view ) {
170            return self::route_url( $url, self::dashboard_path( $args ) );
171        }
172
173        if ( 'post' === $view ) {
174            return self::post_url( $url, $args['id'] ?? 0 );
175        }
176
177        return $url;
178    }
179
180    /**
181     * The dashboard root, opened on the section and the days the link names.
182     *
183     * @param array $args The link's `section` and `range`, both optional.
184     * @return string
185     */
186    private static function dashboard_path( array $args ) {
187        $query   = array();
188        $section = sanitize_key( $args['section'] ?? '' );
189
190        if ( '' !== $section ) {
191            $query['section'] = $section;
192        }
193
194        $query += self::date_window_params( $args['range'] ?? null );
195
196        return '/' . ( $query ? '?' . http_build_query( $query, '', '&', PHP_QUERY_RFC3986 ) : '' );
197    }
198
199    /**
200     * The dashboard's date params for a range of whole days, on the site's calendar.
201     *
202     * A "Last N days" window ending today is sent as its preset, so the date picker reads
203     * "Last 7 days" rather than two dates; the day counts mirror the dashboard's presets.
204     *
205     * @param mixed $range `from` and `to` days as `Y-m-d`.
206     * @return array `preset`, or `from` and `to`, or nothing for a range the dashboard cannot open.
207     */
208    private static function date_window_params( $range ) {
209        if ( ! is_array( $range ) || ! is_string( $range['from'] ?? null ) || ! is_string( $range['to'] ?? null ) ) {
210            return array();
211        }
212
213        $zone = wp_timezone();
214        $from = \DateTimeImmutable::createFromFormat( '!Y-m-d', $range['from'], $zone );
215        $to   = \DateTimeImmutable::createFromFormat( '!Y-m-d', $range['to'], $zone );
216
217        if ( ! $from || ! $to || $to < $from ) {
218            return array();
219        }
220
221        $today = ( new \DateTimeImmutable( 'now', $zone ) )->setTime( 0, 0 );
222        $days  = $from->diff( $to )->days + 1;
223
224        if ( $to->getTimestamp() === $today->getTimestamp() && in_array( $days, array( 7, 30, 90, 365 ), true ) ) {
225            return array( 'preset' => 'last-' . $days . '-days' );
226        }
227
228        return array(
229            'from' => $from->format( 'Y-m-d\TH:i:s.vP' ),
230            'to'   => $to->setTime( 23, 59, 59, 999999 )->format( 'Y-m-d\TH:i:s.vP' ),
231        );
232    }
233
234    /**
235     * The post detail page, or `$url` when there is no real post to show.
236     *
237     * @param string $url     Stats URL for the post.
238     * @param mixed  $post_id The post.
239     * @return string
240     */
241    private static function post_url( $url, $post_id ) {
242        $post_id = (int) $post_id;
243
244        if ( $post_id <= 0 ) {
245            return $url;
246        }
247
248        return self::route_url( $url, '/post/' . $post_id );
249    }
250
251    /**
252     * A dashboard route, or `$url` for a user who cannot read Stats there.
253     *
254     * Wins even where the link would otherwise point at Calypso: this dashboard is the site's
255     * analytics UI and exists only in wp-admin, so the admin-interface preference doesn't apply.
256     * Checked here even where the caller already gates on the same primitives: the filter is public.
257     *
258     * @param string $url  Stats URL.
259     * @param string $path Dashboard route.
260     * @return string
261     */
262    private static function route_url( $url, $path ) {
263        if ( ! Capabilities::current_user_can_open_stats() ) {
264            return $url;
265        }
266
267        return Analytics::dashboard_url( $path );
268    }
269}