Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 143
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Podcast_Stats_Endpoint
0.00% covered (danger)
0.00%
0 / 143
0.00% covered (danger)
0.00%
0 / 9
182
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 register
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 register_routes
0.00% covered (danger)
0.00%
0 / 78
0.00% covered (danger)
0.00%
0 / 1
2
 permission_check
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 read_summary
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 read_overview
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 read_episode_totals
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 read_episode_detail
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 forward
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * Local Jetpack-side REST proxy for podcast stats.
4 *
5 * @package automattic/jetpack-podcast
6 */
7
8namespace Automattic\Jetpack\Podcast;
9
10use Automattic\Jetpack\Connection\Client;
11use Jetpack_Options;
12use WP_Error;
13use WP_REST_Controller;
14use WP_REST_Request;
15use WP_REST_Response;
16use WP_REST_Server;
17
18/**
19 * Forwards podcast stats `wp.apiFetch` calls from the dashboard SPA to the
20 * wpcom-side podcast-stats endpoints. Browser-side wpcom-proxy-request only
21 * authenticates from wpcom origins, so Atomic admin (which is the whole point
22 * of this proxy) needs the request to go server-to-server with the blog
23 * token. Simple sites pass through `Client::wpcom_json_api_request_as_user`
24 * via the in-process IS_WPCOM short-circuit, so the round-trip cost is negligible.
25 */
26class Podcast_Stats_Endpoint extends WP_REST_Controller {
27
28    use Relay_Response;
29
30    /**
31     * Wire up routes.
32     */
33    public static function init() {
34        add_action( 'rest_api_init', array( self::class, 'register' ) );
35    }
36
37    /**
38     * Registers the REST routes on the `rest_api_init` hook.
39     *
40     * Instantiated here, rather than eagerly, so the endpoint class only loads
41     * on requests that reach `rest_api_init`. Static so the callback can be
42     * unregistered.
43     */
44    public static function register() {
45        ( new self() )->register_routes();
46    }
47
48    /**
49     * Register the four stats proxy routes the dashboard SPA consumes.
50     */
51    public function register_routes() {
52        $this->namespace = 'wpcom/v2';
53        $this->rest_base = 'podcast-stats';
54
55        // Period summary (top apps/countries/episodes for a date range).
56        register_rest_route(
57            $this->namespace,
58            $this->rest_base,
59            array(
60                'methods'             => WP_REST_Server::READABLE,
61                'callback'            => array( $this, 'read_summary' ),
62                'permission_callback' => array( $this, 'permission_check' ),
63                'args'                => array(
64                    'from'  => array(
65                        'type'     => 'string',
66                        'required' => false,
67                    ),
68                    'to'    => array(
69                        'type'     => 'string',
70                        'required' => false,
71                    ),
72                    'limit' => array(
73                        'type'     => 'integer',
74                        'required' => false,
75                    ),
76                ),
77            )
78        );
79
80        // Period-independent overview (all-time totals, preset windows, top day).
81        register_rest_route(
82            $this->namespace,
83            $this->rest_base . '/overview',
84            array(
85                'methods'             => WP_REST_Server::READABLE,
86                'callback'            => array( $this, 'read_overview' ),
87                'permission_callback' => array( $this, 'permission_check' ),
88                'args'                => array(
89                    'limit' => array(
90                        'type'     => 'integer',
91                        'required' => false,
92                    ),
93                ),
94            )
95        );
96
97        // Plays + duration for a batch of episode post IDs.
98        register_rest_route(
99            $this->namespace,
100            $this->rest_base . '/episode-totals',
101            array(
102                'methods'             => WP_REST_Server::READABLE,
103                'callback'            => array( $this, 'read_episode_totals' ),
104                'permission_callback' => array( $this, 'permission_check' ),
105                'args'                => array(
106                    'post_ids' => array(
107                        'type'     => 'string',
108                        'required' => true,
109                    ),
110                ),
111            )
112        );
113
114        // Per-episode detail stats for a date range.
115        register_rest_route(
116            $this->namespace,
117            $this->rest_base . '/episode/(?P<post_id>\d+)',
118            array(
119                'methods'             => WP_REST_Server::READABLE,
120                'callback'            => array( $this, 'read_episode_detail' ),
121                'permission_callback' => array( $this, 'permission_check' ),
122                'args'                => array(
123                    'post_id' => array(
124                        'type'     => 'integer',
125                        'required' => true,
126                    ),
127                    'from'    => array(
128                        'type'     => 'string',
129                        'required' => false,
130                    ),
131                    'to'      => array(
132                        'type'     => 'string',
133                        'required' => false,
134                    ),
135                ),
136            )
137        );
138    }
139
140    /**
141     * Permission callback. Stats are surfaced in the dashboard SPA which is
142     * limited to users who can edit the site's posts.
143     *
144     * @return true|WP_Error
145     */
146    public function permission_check() {
147        if ( ! current_user_can( 'edit_posts' ) ) {
148            return new WP_Error(
149                'rest_forbidden',
150                __( 'Sorry, you are not allowed to view podcast stats for this site.', 'jetpack-podcast' ),
151                array( 'status' => rest_authorization_required_code() )
152            );
153        }
154        return true;
155    }
156
157    /**
158     * GET /wpcom/v2/podcast-stats — period summary.
159     *
160     * @param WP_REST_Request $request Full details about the request.
161     *
162     * @return WP_REST_Response|WP_Error
163     */
164    public function read_summary( WP_REST_Request $request ) {
165        return $this->forward(
166            'podcast-stats',
167            array(
168                'from'  => $request->get_param( 'from' ),
169                'to'    => $request->get_param( 'to' ),
170                'limit' => $request->get_param( 'limit' ),
171            )
172        );
173    }
174
175    /**
176     * GET /wpcom/v2/podcast-stats/overview — period-independent overview.
177     *
178     * @param WP_REST_Request $request Full details about the request.
179     *
180     * @return WP_REST_Response|WP_Error
181     */
182    public function read_overview( WP_REST_Request $request ) {
183        return $this->forward(
184            'podcast-stats/overview',
185            array(
186                'limit' => $request->get_param( 'limit' ),
187            )
188        );
189    }
190
191    /**
192     * GET /wpcom/v2/podcast-stats/episode-totals — plays + duration per episode.
193     *
194     * @param WP_REST_Request $request Full details about the request.
195     *
196     * @return WP_REST_Response|WP_Error
197     */
198    public function read_episode_totals( WP_REST_Request $request ) {
199        return $this->forward(
200            'podcast-stats/episode-totals',
201            array(
202                'post_ids' => $request->get_param( 'post_ids' ),
203            )
204        );
205    }
206
207    /**
208     * GET /wpcom/v2/podcast-stats/episode/{post_id} — per-episode detail.
209     *
210     * @param WP_REST_Request $request Full details about the request.
211     *
212     * @return WP_REST_Response|WP_Error
213     */
214    public function read_episode_detail( WP_REST_Request $request ) {
215        $post_id = (int) $request['post_id'];
216
217        return $this->forward(
218            sprintf( 'podcast-stats/episode/%d', $post_id ),
219            array(
220                'from' => $request->get_param( 'from' ),
221                'to'   => $request->get_param( 'to' ),
222            )
223        );
224    }
225
226    /**
227     * Forward a GET to `public-api.wordpress.com/wpcom/v2/sites/{blog_id}/{sub_path}`
228     * as the current user, dropping query args whose value is null/''.
229     *
230     * @param string $sub_path Sub-path under `/sites/{blog_id}/` (no leading slash).
231     * @param array  $query    Query args to append.
232     *
233     * @return WP_REST_Response|WP_Error
234     */
235    private function forward( $sub_path, $query ) {
236        $blog_id = (int) Jetpack_Options::get_option( 'id' );
237        if ( ! $blog_id ) {
238            return new WP_Error(
239                'site-not-connected',
240                __( 'Site is not connected to WordPress.com.', 'jetpack-podcast' ),
241                array( 'status' => 400 )
242            );
243        }
244
245        $query = array_filter(
246            $query,
247            static function ( $value ) {
248                return null !== $value && '' !== $value;
249            }
250        );
251
252        $path = sprintf( '/sites/%d/%s', $blog_id, $sub_path );
253        if ( ! empty( $query ) ) {
254            $path = add_query_arg( $query, $path );
255        }
256
257        $response = Client::wpcom_json_api_request_as_user(
258            $path,
259            '2',
260            array(
261                'method'  => 'GET',
262                'headers' => array( 'content-type' => 'application/json' ),
263                'timeout' => 15,
264            ),
265            null,
266            'wpcom'
267        );
268
269        return $this->relay_response( $response );
270    }
271}