Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.73% covered (success)
92.73%
51 / 55
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Stats_Links
92.73% covered (success)
92.73%
51 / 55
88.89% covered (warning)
88.89%
8 / 9
26.26
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
 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 such page registered for this user, 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            && '' === menu_page_url( 'stats', false )
87            && Capabilities::current_user_can_view_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.
163     * @return string
164     */
165    public static function filter_url( $url, $args ) {
166        $view = is_array( $args ) ? ( $args['view'] ?? null ) : null;
167
168        if ( 'dashboard' === $view ) {
169            return self::route_url( $url, '/' );
170        }
171
172        if ( 'post' === $view ) {
173            return self::post_url( $url, $args['id'] ?? 0 );
174        }
175
176        return $url;
177    }
178
179    /**
180     * The post detail page, or `$url` when there is no real post to show.
181     *
182     * @param string $url     Stats URL for the post.
183     * @param mixed  $post_id The post.
184     * @return string
185     */
186    private static function post_url( $url, $post_id ) {
187        $post_id = (int) $post_id;
188
189        if ( $post_id <= 0 ) {
190            return $url;
191        }
192
193        return self::route_url( $url, '/post/' . $post_id );
194    }
195
196    /**
197     * A dashboard route, or `$url` for a user who cannot read Stats there.
198     *
199     * Wins even where the link would otherwise point at Calypso: this dashboard is the site's
200     * analytics UI and exists only in wp-admin, so the admin-interface preference doesn't apply.
201     * Checked here even where the caller already gates on the same primitives: the filter is public.
202     *
203     * @param string $url  Stats URL.
204     * @param string $path Dashboard route.
205     * @return string
206     */
207    private static function route_url( $url, $path ) {
208        if ( ! Capabilities::current_user_can_view_stats() ) {
209            return $url;
210        }
211
212        return Analytics::dashboard_url( $path );
213    }
214}