PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
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 13.7.2 All 507 releases
← All changes | jetpack_vendor/automattic/jetpack-stats/src/class-wpcom-stats.php +193 -27 13.6.2 → 16.3-beta View file →
@@ -7,8 +7,9 @@
7 7
8 8 namespace Automattic\Jetpack\Stats;
9 9
10 10 use Automattic\Jetpack\Connection\Client;
11 +use Automattic\Jetpack\Status\Host;
11 12 use Jetpack_Options;
12 13 use WP_Error;
13 14
14 15 /**
@@ -49,8 +50,22 @@
49 50 */
50 51 protected $resource;
51 52
52 53 /**
54 + * If the site is on WPCOM Simple.
55 + *
56 + * @var bool
57 + */
58 + protected $is_wpcom_simple;
59 +
60 + /**
61 + * The constructor.
62 + */
63 + public function __construct() {
64 + $this->is_wpcom_simple = ( new Host() )->is_wpcom_simple();
65 + }
66 +
67 + /**
53 68 * Get site's stats.
54 69 *
55 70 * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/
56 71 * @param array $args Optional query parameters.
@@ -94,8 +109,21 @@
94 109 return $this->fetch_stats( $args );
95 110 }
96 111
97 112 /**
113 + * Get site's archive pages by views.
114 + *
115 + * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/archives/
116 + * @param array $args Optional query parameters.
117 + * @return array|WP_Error
118 + */
119 + public function get_archives( $args = array() ) {
120 + $this->resource = 'archives';
121 +
122 + return $this->fetch_stats( $args );
123 + }
124 +
125 + /**
98 126 * Get the details of a single video.
99 127 *
100 128 * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/video/%24post_id/
101 129 * @param int $post_id The video's ID.
@@ -232,8 +260,21 @@
232 260 return $this->fetch_stats( $args );
233 261 }
234 262
235 263 /**
264 + * Get site's views by location.
265 + *
266 + * @param string $geo_mode The type of location to fetch views for (country, region, city).
267 + * @param array $args Optional query parameters.
268 + * @return array|WP_Error
269 + */
270 + public function get_views_by_location( $geo_mode, $args = array() ) {
271 + $this->resource = sprintf( 'location-views/%s', $geo_mode );
272 +
273 + return $this->fetch_stats( $args );
274 + }
275 +
276 + /**
236 277 * Get site's followers.
237 278 *
238 279 * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/followers/
239 280 * @param array $args Optional query parameters.
@@ -295,9 +336,34 @@
295 336 * @param array $args Optional query parameters.
296 337 * @return array|WP_Error
297 338 */
298 339 public function get_total_post_views( $args = array() ) {
340 + if ( $this->is_wpcom_simple ) {
341 + $post_ids = isset( $args['post_ids'] ) ? array_map( 'absint', explode( ',', $args['post_ids'] ) ) : array();
342 + $escaped_post_ids = implode( ',', $post_ids );
299 343
344 + $number_of_days = isset( $args['num'] ) ? absint( $args['num'] ) : 1;
345 + // It's the same function used in WPCOM simple.
346 + // @phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
347 + $end_date = $args['end'] ?? date( 'Y-m-d' );
348 +
349 + $stats = $this->fetch_stats_on_wpcom_simple( $end_date, $number_of_days, $escaped_post_ids );
350 +
351 + $post_views = $stats['-'] ?? array();
352 +
353 + $posts = array_map(
354 + function ( $post_id ) use ( $post_views ) {
355 + return array(
356 + 'ID' => $post_id,
357 + 'views' => $post_views[ $post_id ] ?? 0,
358 + );
359 + },
360 + $post_ids
361 + );
362 +
363 + return array( 'posts' => $posts );
364 + }
365 +
300 366 $this->resource = 'views/posts';
301 367
302 368 return $this->fetch_stats( $args );
303 369 }
@@ -378,11 +444,11 @@
378 444 */
379 445 protected function fetch_stats( $args = array() ) {
380 446 $endpoint = $this->build_endpoint();
381 447 $api_version = self::STATS_REST_API_VERSION;
382 - $cache_key = md5( implode( '|', array( $endpoint, $api_version, wp_json_encode( $args ) ) ) );
448 + $cache_key = md5( implode( '|', array( $endpoint, $api_version, wp_json_encode( $args, JSON_UNESCAPED_SLASHES ) ) ) );
383 449 $transient_name = self::STATS_CACHE_TRANSIENT_PREFIX . $cache_key;
384 - $stats_cache = get_transient( $transient_name );
450 + $stats_cache = $this->should_bypass_cache() ? false : get_transient( $transient_name );
385 451
386 452 if ( $stats_cache ) {
387 453 $time = key( $stats_cache );
388 454 $data = $stats_cache[ $time ]; // WP_Error or string (JSON encoded object).
@@ -395,10 +461,21 @@
395 461 }
396 462
397 463 $wpcom_stats = $this->fetch_remote_stats( $endpoint, $args );
398 464
465 + /*
466 + * Connection failures stop being true as soon as the site reconnects. Do not cache
467 + * them: otherwise the next request keeps returning the old failure after recovery.
468 + *
469 + * `no_possible_tokens` is what the connection package reports for a missing blog token
470 + * since it started naming the reason; `missing_token` is what older versions still return.
471 + */
472 + if ( is_wp_error( $wpcom_stats ) && in_array( $wpcom_stats->get_error_code(), array( 'missing_token', 'no_possible_tokens', 'site_not_connected' ), true ) ) {
473 + return $wpcom_stats;
474 + }
475 +
399 476 // To reduce size in storage: store with time as key, store JSON encoded data.
400 - $cached_value = is_wp_error( $wpcom_stats ) ? $wpcom_stats : wp_json_encode( $wpcom_stats );
477 + $cached_value = is_wp_error( $wpcom_stats ) ? $wpcom_stats : wp_json_encode( $wpcom_stats, JSON_UNESCAPED_SLASHES );
401 478
402 479 /**
403 480 * Filters the expiration time for the stats cache.
404 481 *
@@ -421,10 +498,14 @@
421 498 * Fetches stats data from WPCOM or local Cache. Caches locally for 5 minutes.
422 499 *
423 500 * Unlike the above function, this caches data in the post meta table. As such,
424 501 * it prevents wp_options from blowing up when retrieving views for large numbers
425 - * of posts at the same time. However, the final response is the same as above.
502 + * of posts at the same time.
426 503 *
504 + * This function returns valid arrays and WP_Error objects from cache if within the expiration period.
505 + * If the cached entry is malformed or invalid, a refresh is triggered regardless of cache time.
506 + * This self-healing behavior reduces API calls when remote fetch fails, but ensures data validity.
507 + *
427 508 * @param array $args Query parameters.
428 509 * @param int $post_id Post ID to acquire stats for.
429 510 *
430 511 * @return array|WP_Error
@@ -431,41 +512,66 @@
431 512 */
432 513 protected function fetch_post_stats( $args, $post_id ) {
433 514 $endpoint = $this->build_endpoint();
434 515 $meta_name = '_' . self::STATS_CACHE_TRANSIENT_PREFIX;
435 - $stats_cache = get_post_meta( $post_id, $meta_name );
516 + $stats_cache = get_post_meta( $post_id, $meta_name, false );
436 517
437 518 if ( $stats_cache ) {
438 519 $data = reset( $stats_cache );
439 520
440 - if (
441 - ! is_array( $data )
442 - || empty( $data )
443 - || is_wp_error( $data )
444 - ) {
445 - return $data;
446 - }
521 + // Check if we have a valid cache structure with a time key.
522 + if ( is_array( $data ) && ! empty( $data ) ) {
523 + $time = key( $data );
447 524
448 - $time = key( $data );
449 - $views = $data[ $time ] ?? null;
525 + // If we have a numeric time, check if cache is still valid.
526 + if ( is_numeric( $time ) ) {
527 + /** This filter is already documented in projects/packages/stats/src/class-wpcom-stats.php */
528 + $expiration = apply_filters(
529 + 'jetpack_fetch_stats_cache_expiration',
530 + self::STATS_CACHE_EXPIRATION_IN_MINUTES * MINUTE_IN_SECONDS
531 + );
450 532
451 - // Bail if data is malformed.
452 - if ( ! is_numeric( $time ) || ! is_array( $views ) ) {
453 - return $data;
454 - }
533 + // If within cache period, return cached data after type validation.
534 + if ( ( time() - $time ) < $expiration ) {
535 + $cached_value = $data[ $time ];
455 536
456 - /** This filter is already documented in projects/packages/stats/src/class-wpcom-stats.php */
457 - $expiration = apply_filters(
458 - 'jetpack_fetch_stats_cache_expiration',
459 - self::STATS_CACHE_EXPIRATION_IN_MINUTES * MINUTE_IN_SECONDS
460 - );
537 + // If it's an array or WP_Error, handle appropriately.
538 + if ( is_wp_error( $cached_value ) ) {
539 + return $cached_value;
540 + }
541 + if ( is_array( $cached_value ) ) {
542 + return array_merge( array( 'cached_at' => $time ), $cached_value );
543 + }
461 544
462 - if ( ( time() - $time ) < $expiration ) {
463 - return array_merge( array( 'cached_at' => $time ), $views );
545 + // For any other unexpected type, treat as malformed cache.
546 + // Fall through to refresh.
547 + }
548 + }
464 549 }
465 550 }
466 551
552 + // Cache doesn't exist, is expired, or is malformed - refresh it.
553 + return $this->refresh_post_stats_cache( $endpoint, $args, $post_id, $meta_name );
554 + }
555 +
556 + /**
557 + * Force fetch stats from WPCOM, and always update cache.
558 + *
559 + * This function will cache the result regardless of whether the fetch succeeds
560 + * or fails. This ensures that failed requests are also cached, reducing the
561 + * frequency of API calls when the remote service is experiencing issues.
562 + *
563 + * @param string $endpoint The stats endpoint.
564 + * @param array $args The query arguments.
565 + * @param int $post_id The post ID.
566 + * @param string $meta_name The meta name.
567 + *
568 + * @return array|WP_Error
569 + */
570 + protected function refresh_post_stats_cache( $endpoint, $args, $post_id, $meta_name ) {
467 571 $wpcom_stats = $this->fetch_remote_stats( $endpoint, $args );
572 +
573 + // Always cache the result, even if it's an error or empty.
468 574 update_post_meta( $post_id, $meta_name, array( time() => $wpcom_stats ) );
469 575
470 576 return $wpcom_stats;
471 577 }
@@ -470,8 +576,44 @@
470 576 return $wpcom_stats;
471 577 }
472 578
473 579 /**
580 + * Whether the caller has asked for an answer newer than the cached one.
581 + *
582 + * A site that has just connected, or just bought a plan, carries answers from before it did
583 + * -- including failures, which are cached like any other answer. Both markers are the ones
584 + * `Stats_Admin\WPCOM_Client` already honours, so a page load clears every layer or none.
585 + *
586 + * The dashboard asks for its data over REST, and those requests carry none of the page's
587 + * query, so the marker has to be read from the page that sent them as well.
588 + *
589 + * Limited to users who can view stats: `fetch_stats()` also serves public widgets and
590 + * blocks, and those must not skip the cache because a visitor supplied a query arg or a
591 + * Referer that happens to contain one of the markers.
592 + *
593 + * @return bool
594 + */
595 + protected function should_bypass_cache() {
596 + if ( ! current_user_can( 'view_stats' ) ) {
597 + return false;
598 + }
599 +
600 + foreach ( array( 'force_refresh', 'statsPurchaseSuccess' ) as $marker ) {
601 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended
602 + if ( isset( $_GET[ $marker ] ) ) {
603 + return true;
604 + }
605 +
606 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
607 + if ( isset( $_SERVER['HTTP_REFERER'] ) && false !== strpos( (string) $_SERVER['HTTP_REFERER'], $marker ) ) {
608 + return true;
609 + }
610 + }
611 +
612 + return false;
613 + }
614 +
615 + /**
474 616 * Fetches stats data from WPCOM.
475 617 *
476 618 * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/
477 619 * @param string $endpoint The stats endpoint.
@@ -484,17 +626,41 @@
484 626 }
485 627 $response = Client::wpcom_json_api_request_as_blog( $endpoint, self::STATS_REST_API_VERSION, array( 'timeout' => 20 ) );
486 628 $response_code = wp_remote_retrieve_response_code( $response );
487 629 $response_body = wp_remote_retrieve_body( $response );
630 + $data = json_decode( $response_body, true );
631 + $error_code = is_wp_error( $response ) ? $response->get_error_code() : ( $data['error'] ?? $data['code'] ?? null );
488 632
633 + // Traffic endpoints must expose the same connection errors as the Stats admin API client.
634 + if ( in_array( $error_code, array( 'missing_token', 'no_possible_tokens', 'malformed_token', 'invalid_token', 'unknown_token', 'signature_mismatch' ), true ) ) {
635 + return new WP_Error(
636 + 'site_not_connected',
637 + __( 'This site is not connected to WordPress.com.', 'jetpack-stats-pkg' ),
638 + array( 'status' => 400 )
639 + );
640 + }
641 +
489 642 if ( is_wp_error( $response ) || 200 !== $response_code || empty( $response_body ) ) {
490 643 return is_wp_error( $response ) ? $response : new WP_Error( 'stats_error', 'Failed to fetch Stats from WPCOM' );
491 644 }
492 645
493 - return json_decode( $response_body, true );
646 + return $data;
494 647 }
495 648
496 649 /**
650 + * Fetch the stats when executed in WPCOM Simple.
651 + *
652 + * @param string $end_date The end date.
653 + * @param int $number_of_days The number of days.
654 + * @param string $escaped_post_ids The escaped post ids.
655 + *
656 + * @return array
657 + */
658 + protected function fetch_stats_on_wpcom_simple( $end_date, $number_of_days, $escaped_post_ids ) {
659 + return stats_get_daily_history( false, get_current_blog_id(), 'postviews', 'post_id', $end_date, $number_of_days, " AND post_id IN ($escaped_post_ids)", 0, true );
660 + }
661 +
662 + /**
497 663 * Convert stats array to object after sanity checking the array is valid.
498 664 *
499 665 * @since 0.11.0
500 666 *
@@ -505,9 +671,9 @@
505 671
506 672 if ( is_wp_error( $stats_array ) ) {
507 673 return $stats_array;
508 674 }
509 - $encoded_array = wp_json_encode( $stats_array );
675 + $encoded_array = wp_json_encode( $stats_array, JSON_UNESCAPED_SLASHES );
510 676 if ( ! $encoded_array ) {
511 677 return new WP_Error( 'stats_encoding_error', 'Failed to encode stats array' );
512 678 }
513 679 return json_decode( $encoded_array );