Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.27% covered (success)
98.27%
170 / 173
82.35% covered (warning)
82.35%
14 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Marketplace_Catalog
98.27% covered (success)
98.27%
170 / 173
82.35% covered (warning)
82.35%
14 / 17
71
0.00% covered (danger)
0.00%
0 / 1
 get_products
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 to_catalog
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 get_product
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_product_details
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
8
 to_details
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 to_screenshots_html
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 to_modal_html
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 request
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 to_card
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
4
 fetch_store_products
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
8.02
 blog_id
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 attach_pricing
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 yearly_saving
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 to_category
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
6
 to_variation_ids
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 checkout_url
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 product_url
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * The WordPress.com marketplace catalog, shaped for core's plugin browser.
4 *
5 * @package automattic/jetpack-mu-wpcom
6 */
7
8namespace Automattic\Jetpack\Jetpack_Mu_Wpcom;
9
10use Automattic\Jetpack\Connection\Client;
11
12/**
13 * Reads the plugins WordPress.com sells and normalizes them into the array shape
14 * `WP_Plugin_Install_List_Table` expects to get back from `plugins_api()`.
15 *
16 * Descriptions are most of the payload and are only read by the details modal, so
17 * they are dropped from the cached list and re-fetched per product when it opens.
18 */
19class Marketplace_Catalog {
20
21    /**
22     * Bumped whenever the shape of a cached card or description changes, so sites
23     * do not keep serving data built by the previous version until it expires.
24     */
25    const CACHE_VERSION = 7;
26
27    /**
28     * Transient holding the normalized product list.
29     */
30    const LIST_CACHE_KEY = 'wpcom_marketplace_catalog_v' . self::CACHE_VERSION;
31
32    /**
33     * Transient prefix for a single product's full details.
34     */
35    const PRODUCT_CACHE_PREFIX = 'wpcom_marketplace_product_v' . self::CACHE_VERSION . '_';
36
37    /**
38     * How long a successful read is cached for.
39     */
40    const CACHE_TTL = 6 * HOUR_IN_SECONDS;
41
42    /**
43     * How long a failed read is cached for. Short, but non-zero, so an outage does
44     * not mean an outbound request per page load.
45     */
46    const MISS_CACHE_TTL = 5 * MINUTE_IN_SECONDS;
47
48    /**
49     * Block-level tags, used to work out which stripped tags owe a paragraph break.
50     */
51    private const MODAL_BLOCK_TAGS = array(
52        'address',
53        'article',
54        'aside',
55        'blockquote',
56        'dd',
57        'div',
58        'dl',
59        'dt',
60        'fieldset',
61        'figcaption',
62        'figure',
63        'footer',
64        'h1',
65        'h2',
66        'h3',
67        'h4',
68        'h5',
69        'h6',
70        'header',
71        'li',
72        'main',
73        'nav',
74        'ol',
75        'p',
76        'pre',
77        'section',
78        'table',
79        'tbody',
80        'td',
81        'tfoot',
82        'th',
83        'thead',
84        'tr',
85        'ul',
86    );
87
88    /**
89     * The markup a vendor description keeps once it reaches the details modal.
90     *
91     * Headings run to h6 because core's own `$plugins_allowedtags` does, so keeping
92     * them costs nothing downstream. Everything absent from here is stripped, and
93     * `to_modal_html()` derives from this which block tags owe a paragraph break.
94     */
95    private const MODAL_TAGS = array(
96        'a'          => array(
97            'href'  => array(),
98            'title' => array(),
99        ),
100        'blockquote' => array(),
101        'br'         => array(),
102        'code'       => array(),
103        'em'         => array(),
104        'h1'         => array(),
105        'h2'         => array(),
106        'h3'         => array(),
107        'h4'         => array(),
108        'h5'         => array(),
109        'h6'         => array(),
110        'li'         => array(),
111        'ol'         => array(),
112        'p'          => array(),
113        'strong'     => array(),
114        'ul'         => array(),
115    );
116
117    /**
118     * Every purchasable plugin, keyed by slug, in the order wpcom returns them.
119     *
120     * @return array<string, array> Normalized product data, empty when the catalog cannot be read.
121     */
122    public static function get_products() {
123        $cached = get_transient( self::LIST_CACHE_KEY );
124        if ( is_array( $cached ) ) {
125            return $cached;
126        }
127
128        $response = self::request( '/marketplace/products?type=launched' );
129
130        if ( ! is_array( $response ) || ! is_array( $response['results'] ?? null ) ) {
131            set_transient( self::LIST_CACHE_KEY, array(), self::MISS_CACHE_TTL );
132            return array();
133        }
134
135        $products = self::attach_pricing( self::to_catalog( $response['results'] ), self::fetch_store_products() );
136
137        set_transient( self::LIST_CACHE_KEY, $products, self::CACHE_TTL );
138
139        return $products;
140    }
141
142    /**
143     * Turns an endpoint response into the catalog we list.
144     *
145     * Order is load-bearing: wpcom ranks the response by active subscriptions, so
146     * the best sellers arrive first. Do not sort or re-key what comes back.
147     *
148     * @param array $results Products as the marketplace endpoint returns them.
149     * @return array<string, array> Normalized products, keyed by slug.
150     */
151    public static function to_catalog( array $results ) {
152        $products = array();
153
154        foreach ( $results as $product ) {
155            if ( ! is_array( $product ) || empty( $product['slug'] ) ) {
156                continue;
157            }
158
159            // Retired products stay available to existing subscribers but are no longer sold.
160            if ( ! empty( $product['is_retired'] ) || ! empty( $product['is_hidden'] ) ) {
161                continue;
162            }
163
164            $card = self::to_card( $product );
165
166            $products[ $card['slug'] ] = $card;
167        }
168
169        return $products;
170    }
171
172    /**
173     * One product's card data, as it appears in the browse list.
174     *
175     * @param string $slug Plugin slug.
176     * @return array|null Normalized product data, or null when the slug is not ours.
177     */
178    public static function get_product( $slug ) {
179        $products = self::get_products();
180
181        return $products[ $slug ] ?? null;
182    }
183
184    /**
185     * One product with the long-form fields the details modal renders.
186     *
187     * @param string $slug Plugin slug.
188     * @return array|null Normalized product data, or null when the slug is not ours.
189     */
190    public static function get_product_details( $slug ) {
191        $card = self::get_product( $slug );
192        if ( null === $card ) {
193            return null;
194        }
195
196        $cache_key = self::PRODUCT_CACHE_PREFIX . $slug;
197        $cached    = get_transient( $cache_key );
198        if ( is_array( $cached ) ) {
199            return $cached;
200        }
201
202        $product = self::request( '/marketplace/products/' . rawurlencode( $card['wpcom_product_slug'] ?? $slug ) );
203
204        // The card carries every field the modal needs except the long description.
205        if ( ! is_array( $product ) || empty( $product['slug'] ) ) {
206            $details = self::to_details( $card );
207
208            set_transient( $cache_key, $details, self::MISS_CACHE_TTL );
209
210            return $details;
211        }
212
213        $details = self::to_details( self::to_card( $product ) );
214
215        $raw = is_string( $product['description'] ?? null ) ? $product['description'] : '';
216
217        $description = self::to_modal_html( $raw );
218        if ( '' !== $description ) {
219            $details['sections']['description'] = $description;
220        }
221
222        $screenshots = self::to_screenshots_html( $raw );
223        if ( '' !== $screenshots ) {
224            $details['sections']['screenshots'] = $screenshots;
225        }
226
227        set_transient( $cache_key, $details, self::CACHE_TTL );
228
229        return $details;
230    }
231
232    /**
233     * Strips the fields that only exist to satisfy the browse list.
234     *
235     * The list table reads active_installs unguarded, so a card has to carry it, but
236     * the details modal guards on isset() and renders 0 as "Less Than 10", which we
237     * would be stating as fact about a plugin we have no install count for.
238     *
239     * @param array $card Normalized card data.
240     * @return array
241     */
242    private static function to_details( array $card ) {
243        unset( $card['active_installs'], $card['downloaded'] );
244
245        return $card;
246    }
247
248    /**
249     * Moves the vendor's images into a screenshots section.
250     *
251     * Core constrains images in `#section-screenshots` and nowhere else, which is why
252     * WordPress.org plugins put them there rather than in the description. Rebuilt
253     * from the source URLs alone so none of the vendor's own markup comes with them.
254     *
255     * @param string $html Description as the marketplace endpoint returns it.
256     * @return string Section markup, or an empty string when there are no images.
257     */
258    public static function to_screenshots_html( $html ) {
259        if ( '' === $html || ! preg_match_all( '#<img[^>]*?\s src=[\'"]([^\'"]+)[\'"]#ix', $html, $matches ) ) {
260            return '';
261        }
262
263        $items = '';
264        foreach ( array_unique( $matches[1] ) as $src ) {
265            $url = esc_url( $src );
266            if ( '' !== $url ) {
267                $items .= sprintf( '<li><img src="%s" alt="" /></li>', $url );
268            }
269        }
270
271        return '' === $items ? '' : '<ol>' . $items . '</ol>';
272    }
273
274    /**
275     * Reduces a vendor description to markup core's details modal can render.
276     *
277     * These are WooCommerce.com product pages: layout divs, full-width figures and
278     * inline styles, with the spacing living in a stylesheet the modal does not load.
279     * Left alone they overflow its ~600px column and the text runs together.
280     *
281     * @param string $html Description as the marketplace endpoint returns it.
282     * @return string
283     */
284    public static function to_modal_html( $html ) {
285        if ( '' === $html ) {
286            return '';
287        }
288
289        /*
290         * Core's own modal allowlist has no b or i, so that emphasis would be dropped
291         * one filter later. Normalized first so it survives, and so neither tag is
292         * still around when the block tags are counted below.
293         */
294        $html = preg_replace( '#<(/?)b\b([^>]*)>#i', '<$1strong$2>', $html );
295        $html = preg_replace( '#<(/?)i\b([^>]*)>#i', '<$1em$2>', $html );
296
297        /*
298         * A block tag that is about to be stripped keeps the break it implied, so the
299         * structure the vendor laid out survives. Taken as the block tags minus the
300         * ones we keep, which is what stops the two lists from disagreeing: before
301         * this, a `</td>` lost both its tag and its break and glued that cell onto the
302         * next one. Inline tags are deliberately not in here. The catalog carries 77
303         * `</span>` and breaking on those would split sentences down the middle.
304         */
305        $break = array_diff( self::MODAL_BLOCK_TAGS, array_keys( self::MODAL_TAGS ) );
306        $html  = preg_replace( '#</(?:' . implode( '|', $break ) . ')\s*>#i', "\n\n", $html );
307
308        $html = wp_kses( $html, self::MODAL_TAGS );
309
310        return trim( wpautop( trim( $html ) ) );
311    }
312
313    /**
314     * Reads a wpcom marketplace endpoint.
315     *
316     * @param string $path    Path below the namespace, query string included.
317     * @param string $version API version.
318     * @param string $base    API base, `wpcom` or `rest`.
319     * @return array|null Decoded response body, or null on any failure.
320     */
321    private static function request( $path, $version = '2', $base = 'wpcom' ) {
322        if ( ! method_exists( Client::class, 'wpcom_json_api_request_as_blog' ) ) {
323            return null;
324        }
325
326        $response = Client::wpcom_json_api_request_as_blog( $path, $version, array(), null, $base );
327
328        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
329            return null;
330        }
331
332        $body = json_decode( wp_remote_retrieve_body( $response ), true );
333
334        return is_array( $body ) ? $body : null;
335    }
336
337    /**
338     * Turns one wpcom product into the array a plugin card is rendered from.
339     *
340     * Core reads several of these keys without checking they exist, so every one it
341     * touches is set here even when we have nothing to put in it.
342     *
343     * @param array $product Product data from the marketplace endpoint.
344     * @return array
345     */
346    public static function to_card( array $product ) {
347        $product_slug = (string) ( $product['slug'] ?? '' );
348        $icon         = is_string( $product['icons'] ?? null ) ? $product['icons'] : '';
349
350        // Core resolves installed state from the plugin directory name, and
351        // Marketplace_Products_Updater keys its updates the same way, so the card has
352        // to carry the software slug. The two differ for a handful of products.
353        $slug = (string) ( $product['software_slug'] ?? '' );
354        if ( '' === $slug ) {
355            $slug = $product_slug;
356        }
357
358        return array(
359            'name'               => (string) ( $product['name'] ?? '' ),
360            'slug'               => $slug,
361            'version'            => (string) ( $product['version'] ?? '' ),
362            // wpcom wraps the author name in a placeholder link that goes nowhere.
363            'author'             => wp_strip_all_tags( (string) ( $product['author'] ?? '' ) ),
364            'author_profile'     => '',
365            'contributors'       => array(),
366            'short_description'  => (string) ( $product['short_description'] ?? '' ),
367            'sections'           => array( 'description' => (string) ( $product['short_description'] ?? '' ) ),
368            'icons'              => array(
369                '1x'      => $icon,
370                '2x'      => $icon,
371                'default' => $icon,
372            ),
373            'banners'            => is_array( $product['banners'] ?? null ) ? $product['banners'] : array(),
374            // The payload has an average but no count, and core renders stars from the
375            // average alone, so showing one would mean "(based on 0 ratings)".
376            'rating'             => 0,
377            'num_ratings'        => 0,
378            'ratings'            => array(),
379            'active_installs'    => 0,
380            'downloaded'         => 0,
381            'last_updated'       => (string) ( $product['last_updated'] ?? '' ),
382            'added'              => '',
383            'homepage'           => self::product_url( $product_slug ),
384            'donate_link'        => '',
385            // No download link: these install through a purchase, and its absence is also
386            // what keeps core from offering an Install button in the details modal.
387            'download_link'      => '',
388            'requires'           => false,
389            'requires_php'       => false,
390            'tested'             => '',
391            'upgrade_notice'     => '',
392            // Suppresses core's "WordPress.org Plugin Page" link. These are not on .org.
393            'external'           => true,
394            'wpcom_marketplace'  => true,
395            'wpcom_product_slug' => $product_slug,
396            'wpcom_category'     => self::to_category( $product['tags'] ?? null ),
397            'wpcom_variations'   => self::to_variation_ids( $product['variations'] ?? null ),
398            'wpcom_pricing'      => array(),
399            'wpcom_saving'       => 0,
400        );
401    }
402
403    /**
404     * Reads the store catalog, which is where a variation's slug and price live.
405     *
406     * The marketplace endpoint gives only a numeric product id, and checkout is
407     * addressed by slug, so this is needed for the button as much as for the price.
408     *
409     * @return array<int, array> Keyed by product id.
410     */
411    private static function fetch_store_products() {
412        // Site-scoped first, so prices come back in the site's own currency. Calypso
413        // reads the same two paths in the same order, for the same reason.
414        $blog_id  = self::blog_id();
415        $response = $blog_id > 0 ? self::request( '/sites/' . $blog_id . '/products', '1.1', 'rest' ) : null;
416
417        if ( ! is_array( $response ) ) {
418            $response = self::request( '/products', '1.1', 'rest' );
419        }
420
421        if ( ! is_array( $response ) ) {
422            return array();
423        }
424
425        $store = array();
426        foreach ( $response as $slug => $product ) {
427            if ( ! is_array( $product ) || empty( $product['product_id'] ) ) {
428                continue;
429            }
430
431            $store[ (int) $product['product_id'] ] = array(
432                'slug'  => (string) $slug,
433                'price' => (string) ( $product['cost_display'] ?? '' ),
434                'cost'  => isset( $product['cost'] ) ? (float) $product['cost'] : 0.0,
435            );
436        }
437
438        return $store;
439    }
440
441    /**
442     * This site's WordPress.com blog id.
443     *
444     * `get_wpcom_blog_id()` only answers when IS_WPCOM or IS_ATOMIC is defined, which
445     * a test cannot set without it leaking into every other test in the process. The
446     * connection stores the same id, so falling back to it both covers a connected
447     * site the helper says nothing about and leaves the site-scoped read testable.
448     *
449     * @return int Blog id, or 0 when this site does not have one.
450     */
451    private static function blog_id() {
452        if ( function_exists( 'get_wpcom_blog_id' ) ) {
453            $blog_id = (int) get_wpcom_blog_id();
454
455            if ( $blog_id > 0 ) {
456                return $blog_id;
457            }
458        }
459
460        return class_exists( 'Jetpack_Options' ) ? (int) \Jetpack_Options::get_option( 'id' ) : 0;
461    }
462
463    /**
464     * Resolves each product's variations against the store catalog.
465     *
466     * @param array<string, array> $products Normalized products, keyed by slug.
467     * @param array<int, array>    $store    Store products, keyed by product id.
468     * @return array<string, array>
469     */
470    public static function attach_pricing( array $products, array $store ) {
471        foreach ( $products as $slug => $product ) {
472            $pricing = array();
473
474            foreach ( $product['wpcom_variations'] ?? array() as $term => $product_id ) {
475                if ( isset( $store[ $product_id ] ) ) {
476                    $pricing[ $term ] = $store[ $product_id ];
477                }
478            }
479
480            $products[ $slug ]['wpcom_pricing'] = $pricing;
481            $products[ $slug ]['wpcom_saving']  = self::yearly_saving( $pricing );
482        }
483
484        return $products;
485    }
486
487    /**
488     * How much cheaper a year is than twelve months, as a whole percentage.
489     *
490     * Worth showing because it is not a flat discount: across the catalog it runs from
491     * nothing at all to a third off, so the number is the only honest way to say it.
492     *
493     * @param array $pricing Resolved pricing, keyed by term.
494     * @return int Percentage saved, or 0 when there is nothing to compare or nothing saved.
495     */
496    public static function yearly_saving( array $pricing ) {
497        $yearly  = (float) ( $pricing['yearly']['cost'] ?? 0 );
498        $monthly = (float) ( $pricing['monthly']['cost'] ?? 0 );
499
500        if ( $yearly <= 0 || $monthly <= 0 ) {
501            return 0;
502        }
503
504        $saving = (int) round( ( 1 - $yearly / ( $monthly * 12 ) ) * 100 );
505
506        return max( 0, $saving );
507    }
508
509    /**
510     * The product's category, as something short enough to sit on a card.
511     *
512     * Tags arrive as slug => label. Nearly every product carries "Plugins", which
513     * says nothing on a screen that only lists plugins, so the first tag after that
514     * is the one worth showing.
515     *
516     * @param mixed $tags Tags as the marketplace endpoint returns them.
517     * @return string Category label, or an empty string when there is nothing useful.
518     */
519    private static function to_category( $tags ) {
520        foreach ( is_array( $tags ) ? $tags : array() as $slug => $label ) {
521            if ( 'plugins' !== $slug && is_string( $label ) && '' !== trim( $label ) ) {
522                return trim( $label );
523            }
524        }
525
526        return '';
527    }
528
529    /**
530     * Flattens the endpoint's variations into term => product id.
531     *
532     * @param mixed $variations Variations as the marketplace endpoint returns them.
533     * @return array<string, int>
534     */
535    private static function to_variation_ids( $variations ) {
536        $ids = array();
537
538        foreach ( is_array( $variations ) ? $variations : array() as $term => $variation ) {
539            $product_id = is_array( $variation ) ? (int) ( $variation['product_id'] ?? 0 ) : 0;
540            if ( $product_id > 0 ) {
541                $ids[ (string) $term ] = $product_id;
542            }
543        }
544
545        return $ids;
546    }
547
548    /**
549     * The checkout URL for one variation, which both buys and activates the plugin.
550     *
551     * @param array  $card     Normalized product data.
552     * @param string $term     'yearly' or 'monthly'.
553     * @param string $back_url Where checkout's Back link should return to. Must be on
554     *                         this site's own host, or checkout ignores it.
555     * @return string Checkout URL, or an empty string when there is no such variation.
556     */
557    public static function checkout_url( array $card, $term, $back_url = '' ) {
558        $store_slug = $card['wpcom_pricing'][ $term ]['slug'] ?? '';
559        if ( '' === $store_slug ) {
560            return '';
561        }
562
563        $site_slug = wp_parse_url( home_url(), PHP_URL_HOST );
564
565        $url = sprintf(
566            'https://wordpress.com/checkout/%s/%s',
567            rawurlencode( (string) $site_slug ),
568            rawurlencode( $store_slug )
569        );
570
571        /*
572         * Without this, Back leaves for whichever Calypso page the reader came from,
573         * and for someone arriving straight from wp-admin that is the plan picker.
574         * Checkout allows a back URL on the site's own host, which is where we are.
575         * `add_query_arg()` does not encode values, so the URL is encoded here.
576         */
577        if ( '' !== $back_url ) {
578            $url = add_query_arg( 'checkoutBackUrl', rawurlencode( $back_url ), $url );
579        }
580
581        // Jumps past the plan step, which a marketplace purchase does not have.
582        return $url . '#step2';
583    }
584
585    /**
586     * The WordPress.com page a product is bought from.
587     *
588     * @param string $slug Plugin slug.
589     * @return string
590     */
591    public static function product_url( $slug ) {
592        $site_slug = wp_parse_url( home_url(), PHP_URL_HOST );
593
594        return sprintf(
595            'https://wordpress.com/plugins/%s/%s?ref=wpcom-marketplace-tab',
596            rawurlencode( $slug ),
597            rawurlencode( (string) $site_slug )
598        );
599    }
600}