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