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