PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
jetpack / jetpack_vendor / automattic / jetpack-newsletter / src / class-subscriber-stats-controller.php

class-subscriber-stats-controller.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-beta, at jetpack_vendor/automattic/jetpack-newsletter/src/class-subscriber-stats-controller.php

444 lines 13.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Newsletter subscriber stats REST proxy.
4 *
5 * @package automattic/jetpack-newsletter
6 */
7
8 namespace Automattic\Jetpack\Newsletter;
9
10 use Automattic\Jetpack\Connection\Client;
11 use Automattic\Jetpack\Feature_Flags\Feature_Flags;
12 use Automattic\Jetpack\Status\Host;
13 use WP_Error;
14 use WP_Query;
15 use WP_REST_Controller;
16 use WP_REST_Request;
17 use WP_REST_Response;
18 use WP_REST_Server;
19
20 /**
21 * Proxies subscriber and email-summary requests to WordPress.com Stats.
22 */
23 class 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', 'year' ),
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 0.17.0
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 }
444