Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
17.88% covered (danger)
17.88%
32 / 179
0.00% covered (danger)
0.00%
0 / 11
CRAP
n/a
0 / 0
_wpcom_get_current_blog_id
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
5.93
wpcom_site_has_feature
64.71% covered (warning)
64.71%
11 / 17
0.00% covered (danger)
0.00%
0 / 1
24.89
wpcom_site_can_upload_videos
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
8.30
wpcom_get_site_purchases
64.29% covered (warning)
64.29%
9 / 14
0.00% covered (danger)
0.00%
0 / 1
7.64
_wpcom_features_get_simple_site_purchases
0.00% covered (danger)
0.00%
0 / 75
0.00% covered (danger)
0.00%
0 / 1
240
wpcom_datetime_to_iso8601
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
wpcom_product_has_feature
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
wpcom_purchase_has_feature
20.00% covered (danger)
20.00%
2 / 10
0.00% covered (danger)
0.00%
0 / 1
4.05
wpcom_get_product_features
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
182
_convert_product_to_purchase
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
72
wpcom_feature_exists
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * THIS FILE EXISTS VERBATIM IN WPCOM AND WPCOMSH.
4 *
5 * DANGER DANGER DANGER!!!
6 * If you make any changes to this file you must MANUALLY update this file in both WPCOM and WPCOMSH.
7 *
8 * This file provides WPCOM_Features class wrapper functions that make checking for a specific feature easy and uniform
9 * across WPCOM and WPCOMSH.
10 *
11 * @package WPCOM_Features
12 */
13
14/**
15 * Load `WPCOM_Features` class.
16 */
17require_once __DIR__ . '/class-wpcom-features.php';
18
19/**
20 * Internal function to retrieve the current WP.com blog ID depending on the environment.
21 *
22 * @return int The current blog ID.
23 */
24function _wpcom_get_current_blog_id() {
25    if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
26        return get_current_blog_id();
27    }
28
29    /*
30     * Atomic sites have the WP.com blog ID stored as a Jetpack option. This code deliberately
31     * doesn't use `Jetpack_Options::get_option` so it works even when Jetpack has not been loaded.
32     */
33    $jetpack_options = get_option( 'jetpack_options' );
34    if ( is_array( $jetpack_options ) && isset( $jetpack_options['id'] ) ) {
35        return (int) $jetpack_options['id'];
36    }
37
38    return get_current_blog_id();
39}
40
41/**
42 * Whether a given feature is available to the current (or specified) site.
43 *
44 * This function pulls the purchases for a given site and uses WPCOM_Features to check if any of those purchases
45 * include the requested $feature.
46 *
47 * @param string $feature A singular feature.
48 * @param int    $blog_id Optional. Blog ID. Defaults to current blog.
49 *
50 * @return bool Does the site have the feature?
51 */
52function wpcom_site_has_feature( $feature, $blog_id = 0 ) {
53    if ( ! $blog_id ) {
54        $blog_id = _wpcom_get_current_blog_id();
55    }
56
57    $blog = null;
58    if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) {
59        $site_type = 'wpcom';
60    } else {
61        $blog      = get_blog_details( $blog_id, false );
62        $site_type = is_blog_wpcom( $blog ) || is_blog_atomic( $blog ) ? 'wpcom' : 'jetpack';
63    }
64
65    // A8C override for certain sites.
66    if ( $feature === WPCOM_Features::ADVANCED_SEO && in_array( $blog_id, WPCOM_FEATURES::A8C_SITES_WITH_ADDITIONAL_SEO_FEATURES, true ) ) {
67        return true;
68    }
69
70    /*
71     * A8C override for internal P2s
72     */
73    if ( $feature === WPCOM_Features::AI_ASSISTANT && ( function_exists( 'wpcom_is_automattic_p2_site' ) && wpcom_is_automattic_p2_site( $blog_id ) ) ) {
74        return true;
75    }
76
77    /*
78     * A8C override for wp.org sites to enable JP search
79     */
80    if ( $feature === WPCOM_Features::CLASSIC_SEARCH && ( function_exists( 'wpcom_is_wporg_jp_index' ) && wpcom_is_wporg_jp_index( $blog_id ) ) ) {
81        return true;
82    }
83
84    $purchases = wpcom_get_site_purchases( $blog_id );
85
86    if ( isset( $blog->registered ) ) {
87        WPCOM_Features::add_free_plan_purchase( $purchases, $site_type, $blog->registered );
88    }
89
90    return WPCOM_Features::has_feature( $feature, $purchases, $site_type, $blog_id );
91}
92
93/**
94 * Find out if the site can upload video files.
95 *
96 * This checks if the site has either VideoPress or the general video upload capability.
97 * Sites with the UPLOAD_VIDEO_FILES feature can upload videos even without VideoPress
98 * (e.g. Premium plans with the gating-business-q1 sticker).
99 *
100 * @param int $blog_id Blog ID. Defaults to the current blog ID if none is passed.
101 * @return bool Whether the site can upload video files.
102 */
103function wpcom_site_can_upload_videos( $blog_id = 0 ) {
104    if ( ! $blog_id ) {
105        $blog_id = _wpcom_get_current_blog_id();
106    }
107
108    // VideoPress includes video upload capability.
109    // On WPCOM, use wpcom_site_has_videopress() to respect the filter.
110    // On WPCOMSH/Atomic, that function doesn't exist so use direct feature check.
111    if ( function_exists( 'wpcom_site_has_videopress' ) ) {
112        if ( wpcom_site_has_videopress( $blog_id ) ) {
113            return true;
114        }
115    } elseif ( wpcom_site_has_feature( WPCOM_Features::VIDEOPRESS, $blog_id ) ) {
116        return true;
117    }
118
119    // Check for the general video upload feature (Premium+ plans with gating-business-q1 sticker).
120    if ( wpcom_site_has_feature( WPCOM_Features::UPLOAD_VIDEO_FILES, $blog_id ) ) {
121        return true;
122    }
123
124    /**
125     * Filters whether the site can upload video files.
126     *
127     * @param bool $can_upload_videos Whether the site can upload video files.
128     * @param int  $blog_id Blog ID.
129     */
130    return apply_filters( 'wpcom_site_can_upload_videos', false, $blog_id );
131}
132
133/**
134 * Returns a list of purchased products.
135 *
136 * This function checks if we're on an Atomic (WPCOMSH) or Simple (WPCOM) site, and pulls the purchases for that current
137 * site.
138 *
139 * @throws Error If $blog_id !== current_blog_id on Atomic sites.
140 *
141 * @param int $blog_id Optional. Blog ID. Defaults to current blog.
142 *
143 * @return array An array of product objects containing product_slug, product_id, subscribed_date, and expiry_date.
144 */
145function wpcom_get_site_purchases( $blog_id = 0 ) {
146    if ( ! $blog_id ) {
147        $blog_id = _wpcom_get_current_blog_id();
148    }
149
150    if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) {
151        if ( _wpcom_get_current_blog_id() !== $blog_id ) {
152            throw new Error(
153                'Atomic sites do not support looking up features for sites other than the current site.'
154            );
155        }
156
157        // Atomic site (WPCOMSH) purchases are stored in Atomic Persistent Data as a JSON encoded string.
158        $persistent_data = new Atomic_Persistent_Data();
159
160        if ( ! $persistent_data->WPCOM_PURCHASES ) { // phpcs:ignore WordPress.NamingConventions
161            return array();
162        }
163
164        $purchases = (array) json_decode( $persistent_data->WPCOM_PURCHASES ); // phpcs:ignore WordPress.NamingConventions
165
166    } else {
167        // Allow overriding the blog ID for feature checks.
168        $blog_id = apply_filters( 'wpcom_site_has_feature_blog_id', $blog_id );
169
170        $purchases = _wpcom_features_get_simple_site_purchases( $blog_id );
171    }
172
173    return $purchases;
174}
175
176/**
177 * INTERNAL function to fetch purchases for a WPCOM Simple site.
178 * The function will return an empty array if we're running in an Atomic context.
179 *
180 * @param int $blog_id The blog ID to fetch purchases for.
181 * @return array The currently active purchases on the site.
182 */
183function _wpcom_features_get_simple_site_purchases( $blog_id ) {
184    global $wpdb;
185
186    if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) {
187        // Make _super_ sure the function is available.
188        if ( function_exists( '_doing_it_wrong' ) ) {
189            _doing_it_wrong(
190                __FUNCTION__,
191                'Support for this function is only in available in contexts where the WordPress.com Store databases are available.',
192                false // No version.
193            );
194        }
195
196        return array();
197    }
198
199    if ( ! $blog_id ) {
200        $blog_id = _wpcom_get_current_blog_id();
201    }
202
203    // Optional skip, used for suspended/spam/deleted sites.
204    $skip_purchase_lookup = apply_filters( 'wpcom_simple_skip_purchase_lookup', false, $blog_id );
205    if ( $skip_purchase_lookup ) {
206        return array();
207    }
208
209    // 'site_purchases' belong to $global_groups in ./wp-content/object-cache.php
210    $wp_cache_group = 'site_purchases';
211    $wp_cache_found = false;
212
213    // The DB table is included in $wp_cache_key to avoid cache pollution between the production and test store.
214    $wp_cache_key = "$blog_id-{$wpdb->store_subscriptions}";
215
216    // Check wp_cache_get() for $purchases. If none exist $wp_cache_found will be false.
217    $purchases = wp_cache_get( $wp_cache_key, $wp_cache_group, false, $wp_cache_found );
218
219    if ( false !== $wp_cache_found ) {
220        return (array) $purchases;
221    }
222
223    $lock_key   = "{$wp_cache_key}_building";
224    $lock_found = false;
225    // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
226    $lock = wp_cache_get( $lock_key, $wp_cache_group, false, $lock_found );
227    if ( $lock_found ) {
228        // Another request is already rebuilding the purchases cache.
229        // Let's try 2 short sleeps of 200ms to see if that finishes, and if not,
230        // return an empty set of purchases. The DB must be backed up and we don't
231        // want to contribute to the problem.
232        $attempts = 2;
233        while ( $attempts > 0 ) {
234            // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
235            $lock = wp_cache_get( $lock_key, $wp_cache_group, true /* force remote recheck */, $lock_found );
236            if ( ! $lock_found ) {
237                // The lock is no longer in cache, so we can break out of the loop.
238                break;
239            }
240            // Sleep 200ms
241            usleep( 200000 );
242            --$attempts;
243        }
244
245        // Has the first request has populated the cache?
246        $purchases = wp_cache_get( $wp_cache_key, $wp_cache_group, true /* force remote recheck */, $wp_cache_found );
247        if ( false !== $wp_cache_found ) {
248            return (array) $purchases;
249        }
250
251        // It's still not there, return [] as a fallback;
252        // Also let future checks for this blog for the life of this request fail
253        $target_blog_id = $blog_id;
254        add_filter(
255            'wpcom_simple_skip_purchase_lookup',
256            function ( $skip, $checked_blog_id ) use ( $target_blog_id ) {
257                if ( $checked_blog_id === $target_blog_id ) {
258                    return true; // Skip purchases lookup
259                }
260                return $skip;
261            },
262            10,
263            2
264        );
265        return array();
266    }
267
268    // Let other requests know that we are rebuilding purchases, to stop multiples of the same
269    // request from stacking up.
270    wp_cache_set( $lock_key, true, $wp_cache_group, 30 ); // (30 second TTL)
271
272    // Get $purchases with a direct SQL query.
273    // We are intentionally NOT using the Purchases API as this code needs to be runnable
274    // in some contexts where the billing code-base is not available.
275    // See pdqkMK-18D-p2 for more discussion and context.
276
277    // phpcs:ignore WordPress.DB.DirectDatabaseQuery
278    $purchases = $wpdb->get_results(
279        $wpdb->prepare(
280            "
281                    SELECT
282                        product.product_slug,
283                        product.product_id,
284                        product.billing_product_id,
285                        product.product_type,
286                        subscription.subscribed_date,
287                        subscription.expiry AS 'expiry_date',
288                        subscription.id AS subscription_id,
289                        subscription.auto_renew AS user_allows_auto_renew
290                    FROM `$wpdb->store_subscriptions` AS subscription
291                    LEFT JOIN `$wpdb->store_products` AS product ON subscription.product_id = product.product_id
292                    WHERE
293                        subscription.blog_id = %d
294                        AND subscription.active = 1
295                    ORDER BY subscription.id DESC
296                    ",
297            $blog_id
298        )
299    );
300
301    static $billing_product_data = array();
302
303    $billing_product_ids          = array_unique( wp_list_pluck( $purchases, 'billing_product_id' ) );
304    $billing_product_ids_to_query = array_diff( $billing_product_ids, array_keys( $billing_product_data ) );
305    if ( ! empty( $billing_product_ids_to_query ) ) {
306        // We need to query the billing_products table via a separate query.
307
308        // phpcs:ignore WordPress.DB.DirectDatabaseQuery
309        $billing_products = $wpdb->get_results(
310            "
311                    SELECT
312                        product_id,
313                        product_slug
314                    FROM
315                        `$wpdb->billing_products`
316                    WHERE
317                        " . $wpdb->build_IN_condition( 'product_id', $billing_product_ids_to_query, '%d' ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
318        );
319
320        foreach ( $billing_products as $billing_product ) {
321            $billing_product_data[ $billing_product->product_id ] = $billing_product->product_slug;
322        }
323    }
324
325    // Format the dates to match WPCOMSH data.
326    foreach ( $purchases as $purchase ) {
327        $purchase->billing_product_slug   = $billing_product_data[ $purchase->billing_product_id ] ?? '';
328        $purchase->subscribed_date        = wpcom_datetime_to_iso8601( $purchase->subscribed_date );
329        $purchase->expiry_date            = wpcom_datetime_to_iso8601( $purchase->expiry_date );
330        $purchase->user_allows_auto_renew = ! empty( $purchase->user_allows_auto_renew );
331        // Ensure we remove billing_product_id from the purchase data.
332        unset( $purchase->billing_product_id );
333    }
334
335    /*
336    * Cache the $purchases for 6-8 hours. Otherwise, the cache is invalidated when a purchase is made, using:
337    * add_action( 'subscription_changed', 'clear_wp_cache_site_purchases', 10, 1 );
338    * Found in ./wp-content/mu-plugins/wpcom-features.php
339    */
340    // phpcs:ignore WordPress.WP.AlternativeFunctions.rand_mt_rand
341    wp_cache_set( $wp_cache_key, $purchases, $wp_cache_group, ( 6 * HOUR_IN_SECONDS ) + mt_rand( 1, 2 * 60 * MINUTE_IN_SECONDS ) );
342    wp_cache_delete( $lock_key, $wp_cache_group ); // Release the lock telling other requests we are building purchases.
343
344    return $purchases;
345}
346
347/**
348 * Parse and format a date string to ISO8601, but fall back to $default if the string is bad or '0000-00-00'.
349 *
350 * @param string $date A string representing a datetime that we wish to format to ISO8601.
351 * @param string $default Use this datetime if $date errors. Useful for predictable unit testing. Defaults to 'now'.
352 *
353 * @return string A date string in ISO8601 format.
354 */
355function wpcom_datetime_to_iso8601( $date, $default = 'now' ) {
356    /*
357     * Datetimes containing '0000-00-00' convert to '-001-11-30T00:00:00+00:00' which is not useful, so set it or
358     * empty $date to $default.
359     */
360    if ( empty( $date ) || false !== strpos( $date, '0000-00-00' ) ) {
361        $date = $default;
362    }
363
364    try {
365        return ( new DateTime( $date ) )->format( 'c' );
366    } catch ( Exception $e ) {
367        return ( new DateTime( $default ) )->format( 'c' );
368    }
369}
370
371/**
372 * Checks whether the given product contains the passed feature.
373 *
374 * This function converts atomic supported plan slugs and other product aliases to product objects. It then uses
375 * WPCOM_Features to check if product include the requested $feature.
376 *
377 * Do not pass a Store_Subscription to this function. For that case, use wpcom_purchase_has_feature().
378 *
379 * @param string|int|Store_Product $product A Store_Product object, a product slug, or ID.
380 * @param string                   $feature The name of the feature to check.
381 *
382 * @return bool
383 */
384function wpcom_product_has_feature( $product, $feature ) {
385    if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) {
386        _doing_it_wrong(
387            __FUNCTION__,
388            'Support for this function is only in available in contexts where the store products database is available.',
389            '' // No version.
390        );
391        return false;
392    }
393
394    $purchase = _convert_product_to_purchase( $product );
395    if ( ! $purchase ) {
396        return false;
397    }
398
399    return wpcom_purchase_has_feature( $purchase, $feature );
400}
401
402/**
403 * Checks whether the given purchase (Store_Subscription) contains the passed feature.
404 *
405 * This function is similar to `wpcom_product_has_feature` with the difference that this function can check for legacy
406 * features because purchases contain a `subscribed_date` field whereas products do not.
407 *
408 * Do not pass a Store_Product to this function. For that case, use wpcom_product_has_feature().
409 *
410 * @param Store_Subscription|object $purchase A Store_Subscription object or purchase serialized object.
411 * @param string                    $feature The name of the feature to check.
412 *
413 * @return bool
414 */
415function wpcom_purchase_has_feature( $purchase, $feature ) {
416    if ( $purchase instanceof Store_Subscription ) {
417        /**
418         * We retrieve the product_slug and product_type directly from the Store_Product_List
419         * cache instead of relying on the internals of Store_Subscription to retrieve it.
420         *
421         * The issue is that simply "->product_slug" or "->product_type" can call a custom __get(),
422         * which can issue SQL queries that take > 10ms for information is not needed. This assignment
423         * grabs the value directly from the cached store_products data avoiding any unnecessary queries.
424         */
425        $product = Store_Product_List::get_from_cache()[ $purchase->product_id ];
426
427        $purchase = (object) array(
428            'product_slug'    => $product['product_slug'],
429            'product_id'      => (string) $purchase->product_id,
430            'product_type'    => $product['product_type'],
431            'subscribed_date' => wpcom_datetime_to_iso8601( $purchase->subscribed_date ),
432            'expiry_date'     => wpcom_datetime_to_iso8601( $purchase->expiry ),
433        );
434    }
435
436    return WPCOM_Features::has_feature( $feature, array( $purchase ) );
437}
438
439/**
440 * Returns a list of features that are associated with the passed product.
441 *
442 * @param string|int|Store_Product $product A Store_Product object, a product slug, or ID.
443 *
444 * @return string[]
445 */
446function wpcom_get_product_features( $product ) {
447    if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) {
448        _doing_it_wrong(
449            __FUNCTION__,
450            'Support for this function is only in available in contexts where the store products database is available.',
451            '' // No version.
452        );
453        return array();
454    }
455    // @codeCoverageIgnoreStart
456    $purchase = _convert_product_to_purchase( $product );
457    if ( ! $purchase ) {
458        return array();
459    }
460
461    $cache_group = 'site_purchases';
462    $cache_found = false;
463
464    // Include sticker status in cache key only for Personal and Premium plans since they're affected by feature gating experiments.
465    $sticker_cache_suffix = '';
466    // @phan-suppress-next-line PhanRedundantCondition
467    if ( function_exists( 'has_blog_sticker' ) && $purchase ) {
468        // Use existing WPCOM_Store helper methods to check plan types
469        $is_personal_or_premium_plan = false;
470        if ( isset( $purchase->product_id ) ) {
471            $is_personal_or_premium_plan = WPCOM_Store::is_wpcom_personal_plan( $purchase->product_id ) || WPCOM_Store::is_wpcom_premium_plan( $purchase->product_id );
472        }
473
474        if ( $is_personal_or_premium_plan ) {
475            $current_blog_id = get_current_blog_id();
476            if ( has_blog_sticker( 'gating-business-q1', $current_blog_id ) ) {
477                $sticker_cache_suffix .= '_gatingbq1';
478            }
479        }
480    }
481
482    $cache_key = $purchase->product_slug . $sticker_cache_suffix . filemtime( __DIR__ . '/class-wpcom-features.php' );
483    $features  = wp_cache_get( $cache_key, $cache_group, false, $cache_found );
484
485    if ( false === $cache_found ) {
486        $features = array();
487
488        foreach ( WPCOM_Features::get_feature_slugs() as $feature ) {
489            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
490            if ( wpcom_purchase_has_feature( $purchase, $feature ) ) {
491                $features[] = $feature;
492            }
493        }
494
495        wp_cache_set( $cache_key, $features, $cache_group, DAY_IN_SECONDS );
496    }
497
498    return $features;
499    // @codeCoverageIgnoreEnd
500}
501
502/**
503 * Converts a store product to a purchase object compatible with `WPCOM_Features::has_feature`.
504 *
505 * @param string|int|Store_Product $product A Store_Product object, a product slug, or ID.
506 *
507 * @return null|object
508 */
509function _convert_product_to_purchase( $product ) {
510    if ( ! is_numeric( $product ) && ! is_string( $product ) && ! ( $product instanceof Store_Product ) ) {
511        _doing_it_wrong(
512            __FUNCTION__,
513            'The $purchase parameter should be of type string|int|Store_Product.',
514            false // No version.
515        );
516        return null;
517    }
518
519    if ( is_string( $product ) && ! is_numeric( $product ) ) {
520        require_once WP_CONTENT_DIR . '/admin-plugins/wpcom-billing/class.wpcom-billingdaddy.php';
521        $product = WPCOM_Billingdaddy::store_product_slug_to_product_id( $product );
522    }
523
524    if ( is_numeric( $product ) ) {
525        $product_cache = Store_Product_List::get_from_cache();
526        if ( ! array_key_exists( $product, $product_cache ) ) {
527            return null;
528        }
529        $product = (object) $product_cache[ $product ];
530    }
531
532    return (object) array(
533        'product_slug'    => $product->product_slug,
534        'product_id'      => (string) $product->product_id,
535        'product_type'    => $product->product_type,
536        'subscribed_date' => null,
537        'expiry_date'     => null,
538    );
539}
540
541/**
542 * Checks whether the given feature exists in WordPress.com.
543 *
544 * @param string $feature The name of the feature to check.
545 *
546 * @return bool Whether the feature exists.
547 */
548function wpcom_feature_exists( $feature ) {
549    return WPCOM_Features::feature_exists( $feature );
550}