Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.93% covered (success)
94.93%
206 / 217
53.85% covered (warning)
53.85%
7 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Subscriber_Stats_Controller
94.93% covered (success)
94.93%
206 / 217
53.85% covered (warning)
53.85%
7 / 13
52.35
0.00% covered (danger)
0.00%
0 / 1
 register
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 register_routes
98.63% covered (success)
98.63%
72 / 73
0.00% covered (danger)
0.00%
0 / 1
3
 request_stats
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 is_valid_stats_date
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 proxy_stats_to_wpcom
94.44% covered (success)
94.44%
34 / 36
0.00% covered (danger)
0.00%
0 / 1
7.01
 maybe_map_connection_error
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 site_not_connected_error
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 get_subscribers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_email_summary
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_recent_posts
96.77% covered (success)
96.77%
60 / 62
0.00% covered (danger)
0.00%
0 / 1
24
 can_view
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 overview_enabled
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
1<?php
2/**
3 * Newsletter subscriber stats REST proxy.
4 *
5 * @package automattic/jetpack-newsletter
6 */
7
8namespace Automattic\Jetpack\Newsletter;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Feature_Flags\Feature_Flags;
12use Automattic\Jetpack\Status\Host;
13use WP_Error;
14use WP_Query;
15use WP_REST_Controller;
16use WP_REST_Request;
17use WP_REST_Response;
18use WP_REST_Server;
19
20/**
21 * Proxies subscriber and email-summary requests to WordPress.com Stats.
22 */
23class Subscriber_Stats_Controller extends WP_REST_Controller {
24
25    /**
26     * WordPress.com Stats REST API version proxied by this controller.
27     *
28     * @var string
29     */
30    const STATS_API_VERSION = '1.1';
31
32    /**
33     * Transient prefix for successful WordPress.com Stats responses.
34     *
35     * @var string
36     */
37    const CACHE_TRANSIENT_PREFIX = 'jetpack_newsletter_stats_';
38
39    /**
40     * WordPress.com Stats JSON API rest_base this controller proxies to.
41     *
42     * The local route is `newsletter/stats`; the upstream JSON API is still `stats`.
43     *
44     * @var string
45     */
46    const UPSTREAM_STATS_REST_BASE = 'stats';
47
48    /**
49     * Whether the route registration hook has been added.
50     *
51     * @var bool
52     */
53    private static $registered = false;
54
55    /**
56     * Register the controller once across the admin and REST boot paths.
57     *
58     * @return void
59     */
60    public static function register() {
61        if ( self::$registered ) {
62            return;
63        }
64
65        self::$registered = true;
66        add_action( 'rest_api_init', array( new self(), 'register_routes' ) );
67    }
68
69    /**
70     * Set up the local REST namespace.
71     */
72    public function __construct() {
73        $this->namespace = 'wpcom/v2';
74        $this->rest_base = 'newsletter/stats';
75    }
76
77    /**
78     * Register the Newsletter stats proxy routes.
79     *
80     * Evaluated on `rest_api_init` so filters that land after plugin load can
81     * still toggle the Overview flag. Skipped on Simple: public-api serves the
82     * mapped twin, and `Settings::init()` also runs there via mu-wpcom.
83     *
84     * @return void
85     */
86    public function register_routes() {
87        if ( ( new Host() )->is_wpcom_simple() || ! $this->overview_enabled() ) {
88            return;
89        }
90
91        register_rest_route(
92            $this->namespace,
93            '/' . $this->rest_base . '/subscribers',
94            array(
95                'methods'             => WP_REST_Server::READABLE,
96                'callback'            => array( $this, 'get_subscribers' ),
97                'permission_callback' => array( $this, 'can_view' ),
98                'args'                => array(
99                    'unit'        => array(
100                        'type'    => 'string',
101                        'enum'    => array( 'day', 'week', 'month' ),
102                        'default' => 'day',
103                    ),
104                    'quantity'    => array(
105                        'type'    => 'integer',
106                        'minimum' => 1,
107                        'maximum' => 365,
108                        'default' => 30,
109                    ),
110                    'date'        => array(
111                        'description'       => __( 'Most recent day to include in results (YYYY-MM-DD).', 'jetpack-newsletter' ),
112                        'type'              => 'string',
113                        'pattern'           => '^\d{4}-\d{2}-\d{2}$',
114                        'required'          => true,
115                        'sanitize_callback' => 'sanitize_text_field',
116                        'validate_callback' => array( $this, 'is_valid_stats_date' ),
117                    ),
118                    'stat_fields' => array(
119                        'type'    => 'string',
120                        'enum'    => array( 'subscribers', 'subscribers,subscribers_paid' ),
121                        'default' => 'subscribers,subscribers_paid',
122                    ),
123                ),
124            )
125        );
126
127        register_rest_route(
128            $this->namespace,
129            '/' . $this->rest_base . '/emails/summary',
130            array(
131                'methods'             => WP_REST_Server::READABLE,
132                'callback'            => array( $this, 'get_email_summary' ),
133                'permission_callback' => array( $this, 'can_view' ),
134                'args'                => array(
135                    'quantity'   => array(
136                        'type'    => 'integer',
137                        'minimum' => 1,
138                        'maximum' => 30,
139                        'default' => 30,
140                    ),
141                    'sort_field' => array(
142                        'type'    => 'string',
143                        'enum'    => array( 'opens', 'clicks', 'post_id', 'post_date' ),
144                        'default' => 'post_date',
145                    ),
146                    'sort_order' => array(
147                        'type'    => 'string',
148                        'enum'    => array( 'asc', 'desc' ),
149                        'default' => 'desc',
150                    ),
151                ),
152            )
153        );
154
155        register_rest_route(
156            $this->namespace,
157            '/' . $this->rest_base . '/recent-posts',
158            array(
159                'methods'             => WP_REST_Server::READABLE,
160                'callback'            => array( $this, 'get_recent_posts' ),
161                'permission_callback' => array( $this, 'can_view' ),
162            )
163        );
164    }
165
166    /**
167     * Request Stats from a host override or WordPress.com's Stats REST API.
168     *
169     * @param WP_REST_Request $request        Local REST request.
170     * @param string          $endpoint       Relative Stats endpoint.
171     * @param string[]        $allowed_params Query keys forwarded to the host or WordPress.com.
172     * @return mixed
173     */
174    private function request_stats( $request, $endpoint, $allowed_params ) {
175        $query_args = array();
176        foreach ( $allowed_params as $key ) {
177            $value = $request->get_param( $key );
178            if ( null !== $value ) {
179                $query_args[ $key ] = $value;
180            }
181        }
182
183        /**
184         * Allows a host to provide Newsletter Stats without calling WordPress.com directly.
185         *
186         * @since $$next-version$$
187         *
188         * @param mixed|null $response   Host response, or null to use the default proxy.
189         * @param string     $endpoint   Relative Stats endpoint.
190         * @param array      $query_args Allowlisted request query arguments.
191         */
192        $response = apply_filters(
193            'jetpack_newsletter_stats_pre_request',
194            null,
195            $endpoint,
196            $query_args
197        );
198
199        return $response ?? $this->proxy_stats_to_wpcom( $endpoint, $query_args );
200    }
201
202    /**
203     * Reject dates that are not a real YYYY-MM-DD calendar day.
204     *
205     * @param mixed           $value   Raw date.
206     * @param WP_REST_Request $request Request.
207     * @param string          $param   Parameter name.
208     * @return true|WP_Error
209     */
210    public function is_valid_stats_date( $value, $request, $param ) {
211        $valid = rest_validate_request_arg( $value, $request, $param );
212        if ( true !== $valid ) {
213            return $valid;
214        }
215
216        $parsed = \DateTime::createFromFormat( 'Y-m-d', (string) $value );
217        if ( ! $parsed || $value !== $parsed->format( 'Y-m-d' ) ) {
218            return new WP_Error(
219                'rest_invalid_param',
220                sprintf(
221                    /* translators: %s: Parameter name. */
222                    __( '%s must be a real calendar day in YYYY-MM-DD format.', 'jetpack-newsletter' ),
223                    $param
224                ),
225                array( 'status' => 400 )
226            );
227        }
228
229        return true;
230    }
231
232    /**
233     * Call WordPress.com's Stats REST API, mirroring `WPCOM_Stats::fetch_remote_stats()`.
234     *
235     * Successful responses are cached for five minutes. Errors are not, so a reconnect
236     * is not stuck on a stale failure.
237     *
238     * @param string $endpoint   Relative Stats endpoint.
239     * @param array  $query_args Allowlisted query arguments.
240     * @return mixed|WP_Error
241     */
242    private function proxy_stats_to_wpcom( $endpoint, $query_args ) {
243        $path      = add_query_arg(
244            $query_args,
245            sprintf(
246                '/sites/%d/%s/%s',
247                (int) \Jetpack_Options::get_option( 'id' ),
248                self::UPSTREAM_STATS_REST_BASE,
249                ltrim( $endpoint, '/' )
250            )
251        );
252        $cache_key = self::CACHE_TRANSIENT_PREFIX . md5( implode( '|', array( $path, self::STATS_API_VERSION ) ) );
253        $cached    = get_transient( $cache_key );
254        if ( false !== $cached ) {
255            return json_decode( $cached, true );
256        }
257
258        $response = Client::wpcom_json_api_request_as_blog(
259            $path,
260            self::STATS_API_VERSION,
261            array( 'timeout' => 20 )
262        );
263
264        if ( is_wp_error( $response ) ) {
265            return $this->maybe_map_connection_error( $response );
266        }
267
268        $status     = wp_remote_retrieve_response_code( $response );
269        $body       = json_decode( wp_remote_retrieve_body( $response ), true );
270        $error_code = is_array( $body ) ? ( $body['error'] ?? $body['code'] ?? null ) : null;
271
272        if ( in_array( $error_code, array( 'invalid_token', 'unknown_token', 'signature_mismatch' ), true ) ) {
273            return $this->site_not_connected_error();
274        }
275
276        if ( $status >= 400 ) {
277            $message = is_array( $body )
278                ? ( $body['message'] ?? __( 'An unknown error occurred.', 'jetpack-newsletter' ) )
279                : __( 'An unknown error occurred.', 'jetpack-newsletter' );
280
281            return new WP_Error(
282                $error_code ?? 'unknown_error',
283                $message,
284                array( 'status' => $status )
285            );
286        }
287
288        set_transient( $cache_key, wp_json_encode( $body, JSON_UNESCAPED_SLASHES ), 5 * MINUTE_IN_SECONDS );
289
290        return $body;
291    }
292
293    /**
294     * Map local token failures to a REST 400 so they are not reported as server faults.
295     *
296     * @param WP_Error $error Connection client error.
297     * @return WP_Error
298     */
299    private function maybe_map_connection_error( $error ) {
300        if ( in_array( $error->get_error_code(), array( 'missing_token', 'no_possible_tokens', 'malformed_token' ), true ) ) {
301            return $this->site_not_connected_error();
302        }
303
304        return $error;
305    }
306
307    /**
308     * Error for a site that cannot authenticate with WordPress.com.
309     *
310     * @return WP_Error
311     */
312    private function site_not_connected_error() {
313        return new WP_Error(
314            'site_not_connected',
315            __( 'This site is not connected to WordPress.com.', 'jetpack-newsletter' ),
316            array( 'status' => 400 )
317        );
318    }
319
320    /**
321     * Return subscriber time-series data from WordPress.com.
322     *
323     * @param WP_REST_Request $request Request to proxy.
324     * @return mixed
325     */
326    public function get_subscribers( $request ) {
327        return $this->request_stats( $request, 'subscribers', array( 'unit', 'quantity', 'date', 'stat_fields' ) );
328    }
329
330    /**
331     * Return the latest 30 all-time email summaries from WordPress.com.
332     *
333     * The upstream endpoint caps the response at 30 emails, so aggregate rates only cover those rows.
334     *
335     * @param WP_REST_Request $request Request to proxy.
336     * @return mixed
337     */
338    public function get_email_summary( $request ) {
339        return $this->request_stats( $request, 'emails/summary', array( 'quantity', 'sort_field', 'sort_order' ) );
340    }
341
342    /**
343     * Return recent local posts enriched with email metrics.
344     *
345     * @return array
346     */
347    public function get_recent_posts() {
348        $query           = new WP_Query(
349            array(
350                'post_type'           => 'post',
351                'post_status'         => array( 'publish', 'draft' ),
352                'posts_per_page'      => 10,
353                'orderby'             => 'date',
354                'order'               => 'DESC',
355                'ignore_sticky_posts' => true,
356                'no_found_rows'       => true,
357            )
358        );
359        $summary_request = new WP_REST_Request( 'GET' );
360        $summary_request->set_query_params(
361            array(
362                'quantity'   => 30,
363                'sort_field' => 'post_date',
364                'sort_order' => 'desc',
365            )
366        );
367        $summary = $this->get_email_summary( $summary_request );
368        if ( $summary instanceof WP_REST_Response ) {
369            $summary = $summary->get_data();
370        }
371
372        $summary_available = ! is_wp_error( $summary ) && is_array( $summary );
373        $summary_posts     = $summary_available && isset( $summary['posts'] ) && is_array( $summary['posts'] )
374            ? $summary['posts']
375            : array();
376        $summary_by_id     = array();
377        $email_totals      = array(
378            'sends'        => 0,
379            'uniqueOpens'  => 0,
380            'uniqueClicks' => 0,
381        );
382
383        foreach ( $summary_posts as $summary_post ) {
384            if ( ! is_array( $summary_post ) || empty( $summary_post['id'] ) ) {
385                continue;
386            }
387
388            $summary_by_id[ (int) $summary_post['id'] ] = $summary_post;
389            $email_totals['sends']                     += is_numeric( $summary_post['total_sends'] ?? null ) ? (int) $summary_post['total_sends'] : 0;
390            $email_totals['uniqueOpens']               += is_numeric( $summary_post['unique_opens'] ?? null ) ? (int) $summary_post['unique_opens'] : 0;
391            $email_totals['uniqueClicks']              += is_numeric( $summary_post['unique_clicks'] ?? null ) ? (int) $summary_post['unique_clicks'] : 0;
392        }
393
394        $posts = array();
395        foreach ( $query->posts as $post ) {
396            $metrics = $summary_by_id[ $post->ID ] ?? null;
397            $status  = 'draft' === $post->post_status ? 'draft' : 'publish';
398            $title   = get_the_title( $post );
399            $image   = get_the_post_thumbnail_url( $post, 'thumbnail' );
400
401            $posts[] = array(
402                'id'               => (int) $post->ID,
403                'title'            => '' !== trim( $title ) ? $title : __( '(no title)', 'jetpack-newsletter' ),
404                'status'           => $status,
405                'date'             => get_post_time( DATE_W3C, true, $post ),
406                'url'              => 'draft' === $status ? get_preview_post_link( $post ) : get_permalink( $post ),
407                'image'            => false !== $image ? $image : null,
408                'recipients'       => is_array( $metrics ) && is_numeric( $metrics['total_sends'] ?? null ) ? (int) $metrics['total_sends'] : null,
409                'openRatePercent'  => is_array( $metrics ) && is_numeric( $metrics['opens_rate'] ?? null ) ? (float) $metrics['opens_rate'] : null,
410                'clickRatePercent' => is_array( $metrics ) && is_numeric( $metrics['clicks_rate'] ?? null ) ? (float) $metrics['clicks_rate'] : null,
411            );
412        }
413
414        return array(
415            'posts'         => $posts,
416            'emailTotals'   => $summary_available ? $email_totals : null,
417            'viewAllUrl'    => admin_url( 'edit.php' ),
418            'createPostUrl' => admin_url( 'post-new.php' ),
419        );
420    }
421
422    /**
423     * Restrict subscriber stats to Newsletter administrators.
424     *
425     * @return bool
426     */
427    public function can_view() {
428        return current_user_can( 'manage_options' );
429    }
430
431    /**
432     * Whether the shared Overview flag is on.
433     *
434     * @return bool
435     */
436    private function overview_enabled() {
437        if ( class_exists( Feature_Flags::class, false ) ) {
438            return Feature_Flags::is_enabled( Settings::OVERVIEW_FEATURE_FLAG );
439        }
440
441        return (bool) apply_filters( 'jetpack_feature_flag_enabled_' . Settings::OVERVIEW_FEATURE_FLAG, false );
442    }
443}