Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.43% covered (success)
91.43%
64 / 70
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
Freshly_Pressed
91.43% covered (success)
91.43%
64 / 70
50.00% covered (danger)
50.00%
2 / 4
24.36
0.00% covered (danger)
0.00%
0 / 1
 get_posts
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 query_wpcom_posts
79.17% covered (warning)
79.17%
19 / 24
0.00% covered (danger)
0.00%
0 / 1
10.90
 fetch_posts_from_api
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
9
 format_post
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Fetches the posts currently featured on WordPress.com's Freshly Pressed.
4 *
5 * @package automattic/jetpack-newsletter
6 */
7
8declare( strict_types = 1 );
9
10namespace Automattic\Jetpack\Newsletter;
11
12use Automattic\Jetpack\Status\Host;
13
14/**
15 * Read the WordPress.com Freshly Pressed feed: straight from the database on
16 * Simple sites, and over the public API everywhere else.
17 */
18class Freshly_Pressed {
19    /**
20     * Where the cached list of posts is stored.
21     *
22     * @var string
23     */
24    const TRANSIENT_KEY = 'jetpack_newsletter_freshly_pressed';
25
26    /**
27     * How long a successful response is cached for, in seconds.
28     *
29     * @var int
30     */
31    const CACHE_TTL = HOUR_IN_SECONDS;
32
33    /**
34     * How long a failed response is cached for, in seconds.
35     *
36     * Shorter than a success so an outage recovers quickly, but long enough that
37     * it doesn't become an outgoing request on every dashboard load.
38     *
39     * @var int
40     */
41    const FAILURE_CACHE_TTL = 5 * MINUTE_IN_SECONDS;
42
43    /**
44     * The WordPress.com endpoint listing the Freshly Pressed posts.
45     *
46     * @var string
47     */
48    const API_URL = 'https://public-api.wordpress.com/rest/v1.1/freshly-pressed';
49
50    /**
51     * How many posts to show.
52     *
53     * @var int
54     */
55    const POST_COUNT = 10;
56
57    /**
58     * Get the posts currently featured on Freshly Pressed.
59     *
60     * @since 0.15.0
61     *
62     * @return array[] {
63     *     @type string $title     The post title, as returned by the API.
64     *     @type string $permalink A WordPress.com Reader link to the post.
65     *     @type int    $blog_id   The ID of the site the post belongs to.
66     *     @type int    $post_id   The ID of the post.
67     * }
68     */
69    public static function get_posts(): array {
70        $cached = get_transient( self::TRANSIENT_KEY );
71        if ( is_array( $cached ) ) {
72            return $cached;
73        }
74
75        $posts = ( new Host() )->is_wpcom_simple()
76            ? self::query_wpcom_posts()
77            : self::fetch_posts_from_api();
78
79        // A null means the source failed; an empty array means it had nothing to
80        // show. Only the former is worth retrying soon.
81        set_transient(
82            self::TRANSIENT_KEY,
83            $posts ?? array(),
84            null === $posts ? self::FAILURE_CACHE_TTL : self::CACHE_TTL
85        );
86
87        return $posts ?? array();
88    }
89
90    /**
91     * Read the featured posts straight from the wpcom database.
92     *
93     * `FreshlyPressed::query()` only caches for logged-out visitors, so this is a
94     * real query every time and the caller's transient is what keeps it off the
95     * dashboard's critical path.
96     *
97     * @since 0.15.0
98     *
99     * @return array[]|null The posts, or null if the wpcom plugin isn't available.
100     */
101    private static function query_wpcom_posts(): ?array {
102        if ( ! class_exists( 'FreshlyPressed' ) ) {
103            $plugin = WP_CONTENT_DIR . '/plugins/freshly-pressed.php';
104            if ( ! file_exists( $plugin ) ) {
105                return null;
106            }
107            require_once $plugin;
108        }
109
110        // @phan-suppress-next-line PhanUndeclaredClassMethod -- wpcom-only class, guarded by the class_exists above. Remove once FreshlyPressed is added to wpcom's stub-defs.php and the regenerated stubs land.
111        $result = \FreshlyPressed::get_available_posts( array( 'number' => self::POST_COUNT ) );
112
113        if ( ! isset( $result['posts'] ) || ! is_array( $result['posts'] ) ) {
114            return null;
115        }
116
117        $posts = array();
118        foreach ( $result['posts'] as $featured ) {
119            if ( empty( $featured['blog_id'] ) || empty( $featured['post_id'] ) ) {
120                continue;
121            }
122
123            // A post can be unpublished or deleted after being featured.
124            $post = get_blog_post( $featured['blog_id'], $featured['post_id'] );
125            if ( empty( $post ) ) {
126                continue;
127            }
128
129            // An untitled post would render as a link with nothing to click.
130            $title = trim( (string) $post->post_title );
131            if ( '' === $title ) {
132                continue;
133            }
134
135            $posts[] = self::format_post(
136                (int) $featured['blog_id'],
137                (int) $featured['post_id'],
138                $title
139            );
140        }
141
142        return $posts;
143    }
144
145    /**
146     * Fetch the featured posts from the WordPress.com public API.
147     *
148     * @since 0.15.0
149     *
150     * @return array[]|null The posts, or null if the request failed.
151     */
152    private static function fetch_posts_from_api(): ?array {
153        $response = wp_remote_get(
154            add_query_arg(
155                array(
156                    'number' => self::POST_COUNT,
157                    // Without this the API returns the full content of every post.
158                    'fields' => 'ID,site_ID,title',
159                ),
160                self::API_URL
161            ),
162            array( 'timeout' => 5 )
163        );
164
165        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
166            return null;
167        }
168
169        $body = json_decode( wp_remote_retrieve_body( $response ), true );
170        if ( ! isset( $body['posts'] ) || ! is_array( $body['posts'] ) ) {
171            return null;
172        }
173
174        $posts = array();
175        foreach ( $body['posts'] as $post ) {
176            // An untitled post would render as a link with nothing to click.
177            $title = trim( (string) ( $post['title'] ?? '' ) );
178            if ( empty( $post['ID'] ) || empty( $post['site_ID'] ) || '' === $title ) {
179                continue;
180            }
181
182            $posts[] = self::format_post( (int) $post['site_ID'], (int) $post['ID'], $title );
183        }
184
185        return $posts;
186    }
187
188    /**
189     * Shape one post the way the widget expects it.
190     *
191     * @since 0.15.0
192     *
193     * @param int    $blog_id The ID of the site the post belongs to.
194     * @param int    $post_id The ID of the post.
195     * @param string $title   The post title.
196     * @return array The formatted post.
197     */
198    private static function format_post( int $blog_id, int $post_id, string $title ): array {
199        return array(
200            'title'     => $title,
201            'permalink' => add_query_arg(
202                array(
203                    'algo' => 'freshly-pressed',
204                    'ref'  => 'dashboard_widget',
205                ),
206                sprintf( 'https://wordpress.com/reader/blogs/%d/posts/%d', $blog_id, $post_id )
207            ),
208            'blog_id'   => $blog_id,
209            'post_id'   => $post_id,
210        );
211    }
212}