PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
jetpack / extensions / blocks / premium-content / _inc / subscription-service / class-abstract-token-subscription-service.php

class-abstract-token-subscription-service.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3, at extensions/blocks/premium-content/_inc/subscription-service/class-abstract-token-subscription-service.php

950 lines 31.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * A paywall that exchanges JWT tokens from WordPress.com to allow
4 * a current visitor to view content that has been deemed "Premium content".
5 *
6 * @package Automattic\Jetpack\Extensions\Premium_Content
7 */
8
9 namespace Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service;
10
11 use Automattic\Jetpack\Extensions\Premium_Content\JWT;
12 use WP_Error;
13 use WP_Post;
14 use const Automattic\Jetpack\Extensions\Subscriptions\META_NAME_FOR_POST_TIER_ID_SETTINGS;
15
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit( 0 );
18 }
19
20 /**
21 * Class Abstract_Token_Subscription_Service
22 *
23 * @package Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service
24 */
25 abstract class Abstract_Token_Subscription_Service implements Subscription_Service {
26
27 const JWT_AUTH_TOKEN_COOKIE_NAME = 'wp-jp-premium-content-session'; // wp prefix helps with skipping batcache
28 const DECODE_EXCEPTION_FEATURE = 'memberships';
29 const DECODE_EXCEPTION_MESSAGE = 'Problem decoding provided token';
30 const REST_URL_ORIGIN = 'https://subscribe.wordpress.com/';
31 const BLOG_SUB_ACTIVE = 'active';
32 const BLOG_SUB_PENDING = 'pending';
33 const POST_ACCESS_LEVEL_EVERYBODY = 'everybody';
34 const POST_ACCESS_LEVEL_SUBSCRIBERS = 'subscribers';
35 const POST_ACCESS_LEVEL_PAID_SUBSCRIBERS = 'paid_subscribers';
36 const POST_ACCESS_LEVEL_PAID_SUBSCRIBERS_ALL_TIERS = 'paid_subscribers_all_tiers';
37
38 /**
39 * An optional user_id to query against (omitting this will use either the token or current user id)
40 *
41 * @var int|null
42 */
43 protected $user_id = null;
44
45 /**
46 * Whether editorial permissions satisfy subscription requirements.
47 *
48 * @var bool
49 */
50 private $allow_editor_access = true;
51
52 /**
53 * Constructor
54 *
55 * @param int|null $user_id An optional user_id to query subscriptions against. Uses token from request/cookie or logged-in user information if omitted.
56 */
57 public function __construct( $user_id = null ) {
58 $this->user_id = $user_id;
59 }
60
61 /**
62 * Initialize the token subscription service.
63 *
64 * @inheritDoc
65 */
66 public function initialize() {
67 $this->get_and_set_token_from_request();
68 }
69
70 /**
71 * Set the token from the Request to the cookie and retrieve the token.
72 *
73 * @return string|null
74 */
75 public function get_and_set_token_from_request() {
76 // URL token always has a precedence, so it can overwrite the cookie when new data available.
77 $token = $this->token_from_request();
78 if ( null !== $token ) {
79 $this->set_token_cookie( $token );
80 $this->maybe_link_wpcom_user_id( $token );
81 return $token;
82 }
83
84 return $this->token_from_cookie();
85 }
86
87 /**
88 * Self-heals the local user's wpcom_user_id meta from a freshly-verified magic-link
89 * token, so future requests can resolve this visitor's subscriptions via the
90 * authoritative filter (see get_subscriptions_for_logged_in_user() in access-check.php)
91 * without needing another magic-link round trip once the token/cookie expires.
92 *
93 * Safe because the token's user_id was just verified by WordPress.com's own session
94 * at subscribe.wordpress.com -- never read from anything stored locally. A local
95 * account's email is not proof of identity by itself (anyone with admin access could
96 * type in any email when creating a local user), so this additionally requires the
97 * local account's own email to match the token's blog_subscriber email before linking,
98 * to avoid mis-linking a local account to whichever WordPress.com session happens to
99 * be active in the browser (e.g. a shared device) when the two aren't otherwise related.
100 *
101 * @param string $token The raw token string from the incoming request.
102 * @return void
103 */
104 private function maybe_link_wpcom_user_id( $token ) {
105 if ( ! is_user_logged_in() ) {
106 return;
107 }
108
109 $payload = $this->decode_token( $token );
110 if ( empty( $payload['user_id'] ) || empty( $payload['blog_subscriber'] ) ) {
111 return;
112 }
113
114 $local_user = wp_get_current_user();
115 if ( strcasecmp( $local_user->user_email, $payload['blog_subscriber'] ) !== 0 ) {
116 return;
117 }
118
119 if ( (int) get_user_meta( $local_user->ID, 'wpcom_user_id', true ) === (int) $payload['user_id'] ) {
120 return;
121 }
122
123 update_user_meta( $local_user->ID, 'wpcom_user_id', (int) $payload['user_id'] );
124 }
125
126 /**
127 * Attempt to refresh the current token against the WordPress.com refresh endpoint
128 * and, on success, persist the fresh token in the cookie and return the decoded
129 * payload.
130 *
131 * This is called when a subscriber has a JWT token whose subscription data is
132 * stale (e.g. the cookie contains an old end_date from before a Stripe renewal).
133 * The refresh endpoint accepts the existing token, re-queries billing, and
134 * returns a fresh token reflecting current subscription state.
135 *
136 * @return array|null Decoded fresh payload on success, null on any failure.
137 */
138 public function refresh_token_payload() {
139 $current_token = $this->get_and_set_token_from_request();
140 if ( empty( $current_token ) ) {
141 return null;
142 }
143
144 $fresh_token = $this->fetch_refreshed_token( $current_token );
145 if ( empty( $fresh_token ) ) {
146 return null;
147 }
148
149 $fresh_payload = $this->decode_token( $fresh_token );
150 if ( empty( $fresh_payload ) ) {
151 return null;
152 }
153
154 $this->set_token_cookie( $fresh_token );
155 return $fresh_payload;
156 }
157
158 /**
159 * POST the current token to the WordPress.com memberships token-refresh endpoint
160 * and return a fresh JWT string, or null on any failure.
161 *
162 * Endpoint contract (POST /sites/<site_id>/memberships/token/refresh):
163 * - 200 + { success: true, jwt_token: "<jwt>" } → fresh token; return it.
164 * - 200 + { success: false, ... } → wpcom refused the refresh
165 * (token no longer eligible, or signature/site/user check failed).
166 * Deterministic; clear the cookie so the visitor is routed through the normal
167 * auth flow on the next page load.
168 * - Anything else (non-200, WP_Error, network timeout, malformed body) → transient;
169 * leave the cookie alone so a temporary outage does not mass-log-out subscribers.
170 *
171 * @param string $current_token The token to present for refresh.
172 * @return string|null Fresh token string, or null on failure.
173 */
174 protected function fetch_refreshed_token( $current_token ) {
175 $site_id = (int) $this->get_site_id();
176 if ( $site_id <= 0 ) {
177 return null;
178 }
179
180 $response = wp_remote_post(
181 sprintf(
182 'https://public-api.wordpress.com/rest/v1.1/sites/%d/memberships/token/refresh',
183 $site_id
184 ),
185 array(
186 'timeout' => 5,
187 'headers' => array( 'Content-Type' => 'application/json' ),
188 'body' => wp_json_encode(
189 array( 'jwt_token' => $current_token ),
190 JSON_UNESCAPED_SLASHES
191 ),
192 )
193 );
194
195 if ( is_wp_error( $response ) ) {
196 return null;
197 }
198
199 if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
200 return null;
201 }
202
203 $body = json_decode( wp_remote_retrieve_body( $response ), true );
204 if ( ! is_array( $body ) || ! isset( $body['success'] ) ) {
205 return null;
206 }
207
208 if ( true === $body['success'] ) {
209 if ( ! empty( $body['jwt_token'] ) && is_string( $body['jwt_token'] ) ) {
210 return $body['jwt_token'];
211 }
212 // Malformed 200: success: true but no usable jwt_token. Treat as transient —
213 // leave the cookie alone rather than logging the visitor out over a response
214 // shape problem.
215 return null;
216 }
217
218 // success === false → deterministic auth failure. Clear cookie.
219 self::clear_token_cookie();
220 return null;
221 }
222
223 /**
224 * Whether the token already carries a subscription whose product_id matches one of
225 * the required plans. Used to gate the refresh path: combined with `validate_subscriptions`
226 * having returned false, a match here implies the matching subscription's end_date is
227 * in the past — the only case the refresh endpoint can help with.
228 *
229 * @param int[] $valid_plan_ids Plan IDs required by the post.
230 * @param array $token_subscriptions Subscriptions from the current token (keyed by product_id).
231 * @return bool
232 */
233 public function token_has_matching_product( array $valid_plan_ids, array $token_subscriptions ) {
234 if ( empty( $token_subscriptions ) ) {
235 return false;
236 }
237 foreach ( $valid_plan_ids as $plan_id ) {
238 $product_id = (int) get_post_meta( $plan_id, 'jetpack_memberships_product_id', true );
239 if ( $product_id > 0 && isset( $token_subscriptions[ $product_id ] ) ) {
240 return true;
241 }
242 }
243 return false;
244 }
245
246 /**
247 * Get the site ID for the current site.
248 *
249 * @return int
250 */
251 abstract public function get_site_id();
252
253 /**
254 * Get the token payload .
255 *
256 * @return array
257 */
258 public function get_token_payload() {
259 $token = $this->get_and_set_token_from_request();
260 if ( empty( $token ) ) {
261 return array();
262 }
263 $token_payload = $this->decode_token( $token );
264 if ( ! is_array( $token_payload ) ) {
265 return array();
266 }
267 return $token_payload;
268 }
269
270 /**
271 * Get a token property, otherwise return false.
272 *
273 * @param string $key the property name.
274 *
275 * @return mixed|false
276 */
277 public function get_token_property( $key ) {
278 $token_payload = $this->get_token_payload();
279 if ( ! isset( $token_payload[ $key ] ) ) {
280 return false;
281 }
282 return $token_payload[ $key ];
283 }
284
285 /**
286 * The user is visiting with a subscriber token cookie.
287 *
288 * This is theoretically where the cookie JWT signature verification
289 * thing will happen.
290 *
291 * How to obtain one of these (or what exactly it is) is
292 * still a WIP (see api/auth branch)
293 *
294 * @inheritDoc
295 *
296 * @param array $valid_plan_ids List of valid plan IDs.
297 * @param string $access_level Access level for content.
298 * @param int|null $post_id Post to gate against. Defaults to the loop post, which is only right while rendering it.
299 *
300 * @return bool Whether the user can view the content
301 */
302 public function visitor_can_view_content( $valid_plan_ids, $access_level, $post_id = null ) {
303 global $current_user;
304 $old_user = $current_user; // backup the current user so we can set the current user to the token user for paywall purposes
305
306 $payload = $this->get_token_payload();
307 $is_valid_token = ! empty( $payload );
308
309 if ( $is_valid_token && isset( $payload['user_id'] ) ) {
310 // set the current user to the payload's user id
311 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
312 $current_user = get_user_by( 'id', $payload['user_id'] );
313 }
314
315 $is_blog_subscriber = false;
316 $is_paid_subscriber = false;
317 $subscriptions = array();
318
319 if ( $is_valid_token ) {
320 /**
321 * Allow access to the content if:
322 *
323 * Active: user has a valid subscription
324 */
325 $is_blog_subscriber = in_array(
326 $payload['blog_sub'],
327 array(
328 self::BLOG_SUB_ACTIVE,
329 ),
330 true
331 );
332 $subscriptions = (array) $payload['subscriptions'];
333 $is_paid_subscriber = static::validate_subscriptions( $valid_plan_ids, $subscriptions );
334
335 // Only attempt a refresh in the specific stale-end_date case: the token already carries a
336 // subscription whose product_id matches one of the required plans, but validation failed
337 // (which, given the match, can only be because end_date is in the past). This excludes
338 // free subscribers, tier mismatches, and cancellations from triggering an HTTP call on
339 // every render.
340 if (
341 ! $is_paid_subscriber
342 && ! empty( $valid_plan_ids )
343 && $this->token_has_matching_product( $valid_plan_ids, $subscriptions )
344 ) {
345 $fresh_payload = $this->refresh_token_payload();
346 if ( ! empty( $fresh_payload ) ) {
347 $payload = $fresh_payload;
348 $is_blog_subscriber = isset( $payload['blog_sub'] ) && self::BLOG_SUB_ACTIVE === $payload['blog_sub'];
349 $subscriptions = isset( $payload['subscriptions'] ) ? (array) $payload['subscriptions'] : array();
350 $is_paid_subscriber = static::validate_subscriptions( $valid_plan_ids, $subscriptions );
351 }
352 }
353 }
354
355 $has_access = $this->user_has_access( $access_level, $is_blog_subscriber, $is_paid_subscriber, null === $post_id ? get_the_ID() : $post_id, $subscriptions );
356 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
357 $current_user = $old_user;
358 return $has_access;
359 }
360
361 /**
362 * Check subscription entitlement without granting access for editing the post.
363 *
364 * @since $$next-version$$
365 *
366 * @param array $valid_plan_ids Required subscription plan IDs.
367 * @param string $access_level Required access level.
368 * @param int|null $post_id Post to check, or the loop post when omitted.
369 * @return bool Whether the visitor meets the subscription requirement.
370 */
371 public function visitor_has_subscription_access( $valid_plan_ids, $access_level, $post_id = null ) {
372 $allow_editor_access = $this->allow_editor_access;
373 $this->allow_editor_access = false;
374 try {
375 return $this->visitor_can_view_content( $valid_plan_ids, $access_level, $post_id );
376 } finally {
377 $this->allow_editor_access = $allow_editor_access;
378 }
379 }
380
381 /**
382 * Retrieves the email of the currently authenticated subscriber.
383 *
384 * @return string The email address of the current user.
385 */
386 public function get_subscriber_email() {
387 $email = $this->get_token_property( 'blog_subscriber' );
388 if ( empty( $email ) ) {
389 return '';
390 }
391 return $email;
392 }
393
394 /**
395 * Returns true if the current authenticated user is subscribed to the current site.
396 *
397 * @return boolean
398 */
399 public function is_current_user_subscribed() {
400 return $this->get_token_property( 'blog_sub' ) === 'active';
401 }
402
403 /**
404 * Returns true if the current authenticated user has a pending subscription to the current site.
405 *
406 * @return bool
407 */
408 abstract public function is_current_user_pending_subscriber(): bool;
409
410 /**
411 * Return if the user has access to the content depending on the access level and the user rights
412 *
413 * @param string $access_level Post or blog access level.
414 * @param bool $is_blog_subscriber Is user a subscriber of the blog.
415 * @param bool $is_paid_subscriber Is user a paid subscriber of the blog.
416 * @param int $post_id Post ID.
417 * @param array $user_abbreviated_subscriptions User subscription abbreviated.
418 *
419 * @return bool Whether the user has access to the content.
420 */
421 protected function user_has_access( $access_level, $is_blog_subscriber, $is_paid_subscriber, $post_id, $user_abbreviated_subscriptions ) {
422
423 if ( $this->allow_editor_access && is_user_logged_in() && current_user_can( 'edit_post', $post_id ) ) {
424 // Admin has access
425 $has_access = true;
426 } else {
427 switch ( $access_level ) {
428 case self::POST_ACCESS_LEVEL_EVERYBODY:
429 default:
430 $has_access = true;
431 break;
432 case self::POST_ACCESS_LEVEL_SUBSCRIBERS:
433 $has_access = $is_blog_subscriber || $is_paid_subscriber;
434 break;
435 case self::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS_ALL_TIERS:
436 $has_access = $is_paid_subscriber;
437 break;
438 case self::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS:
439 $has_access = $is_paid_subscriber &&
440 ! $this->maybe_gate_access_for_user_if_post_tier( $post_id, $user_abbreviated_subscriptions );
441 break;
442 }
443 }
444
445 do_action( 'earn_user_has_access', $access_level, $has_access, $is_blog_subscriber, $is_paid_subscriber, $post_id );
446 return $has_access;
447 }
448
449 /**
450 * Check post access for tiers.
451 *
452 * @param int $post_id Current post id.
453 * @param array $user_abbreviated_subscriptions User subscription abbreviated.
454 *
455 * @return bool
456 */
457 private function maybe_gate_access_for_user_if_post_tier( $post_id, $user_abbreviated_subscriptions ) {
458 $tier_id = intval(
459 get_post_meta( $post_id, META_NAME_FOR_POST_TIER_ID_SETTINGS, true )
460 );
461
462 if ( ! $tier_id ) {
463 return false;
464 }
465
466 return $this->maybe_gate_access_for_user_if_tier( $tier_id, $user_abbreviated_subscriptions );
467 }
468
469 /**
470 * Get all plans id that make access valid for a post with this tier id.
471 *
472 * @param int $tier_id Newsletter tier post ID.
473 *
474 * @return array|WP_Error
475 */
476 public static function get_valid_plan_ids_for_tier( int $tier_id ) {
477 // Valid plans are:
478 // - monthly plan with ID $tier_id
479 // - yearly plan related to this $tier_id (in meta jetpack_memberships_tier)
480 // - monthly tiers with same currency and price same or higher than original tier
481 // - yearly plans that are more expensive than the yearly plan linked to the original tier
482
483 $valid_plan_ids = array();
484
485 $all_plans = \Jetpack_Memberships::get_all_plans();
486
487 // Let's get the current tier
488 $tier = null;
489 foreach ( $all_plans as $post ) {
490 if ( $post->ID === $tier_id ) {
491 $tier = $post;
492 break;
493 }
494 }
495
496 if ( $tier === null ) {
497 // We have an error
498 return new WP_Error( 'related-plan-not-found', 'The plan related to the tier cannot be found' );
499 }
500
501 $tier_price = self::find_metadata( $tier, 'jetpack_memberships_price' );
502 $tier_currency = self::find_metadata( $tier, 'jetpack_memberships_currency' );
503 $tier_product_id = self::find_metadata( $tier, 'jetpack_memberships_product_id' );
504
505 if ( $tier_price === null || $tier_currency === null || $tier_product_id === null ) {
506 // There is an issue with the meta
507 return new WP_Error( 'wrong-data-plan-not-found', 'The plan related to the tier is missing data' );
508 }
509
510 $valid_plan_ids[] = $tier_id;
511
512 $tier_price = floatval( $tier_price );
513
514 // At this point we know the post is
515 $annual_tier = null;
516 foreach ( $all_plans as $plan ) {
517 if ( intval( self::find_metadata( $plan, 'jetpack_memberships_tier' ) ) === $tier_id ) {
518 $annual_tier = $plan;
519 break;
520 }
521 }
522
523 $annual_tier_price = null;
524 if ( ! empty( $annual_tier ) ) {
525 $annual_tier_price = floatval( self::find_metadata( $annual_tier, 'jetpack_memberships_price' ) );
526 $valid_plan_ids[] = $annual_tier->ID;
527 }
528
529 foreach ( $all_plans as $post ) {
530 if ( in_array( $post->ID, $valid_plan_ids, true ) ) {
531 continue;
532 }
533
534 $plan_price = self::find_metadata( $post, 'jetpack_memberships_price' );
535 $plan_currency = self::find_metadata( $post, 'jetpack_memberships_currency' );
536 $plan_interval = self::find_metadata( $post, 'jetpack_memberships_interval' );
537
538 if ( $plan_price === null || $plan_currency === null || $plan_interval === null ) {
539 // There is an issue with the meta
540 continue;
541 }
542
543 $plan_price = floatval( $plan_price );
544
545 if ( $tier_currency !== $plan_currency ) {
546 // For now, we don't count if there are different currency (not sure how to convert price in a pure JP env)
547 continue;
548 }
549
550 if ( ( $plan_interval === '1 month' && $plan_price >= $tier_price ) ||
551 ( $annual_tier_price !== null && $plan_interval === '1 year' && $plan_price >= $annual_tier_price )
552 ) {
553 $valid_plan_ids [] = $post->ID;
554 }
555 }
556
557 return $valid_plan_ids;
558 }
559
560 /**
561 * Find metadata in post
562 *
563 * @param WP_Post|object $post Post.
564 * @param string $meta_key Meta to retrieve.
565 *
566 * @return mixed|null
567 */
568 private static function find_metadata( $post, $meta_key ) {
569
570 if ( $post instanceof WP_Post ) {
571 return $post->{$meta_key};
572 }
573
574 foreach ( $post->metadata as $meta ) {
575 if ( $meta->key === $meta_key ) {
576 return $meta->value;
577 }
578 }
579
580 return null;
581 }
582
583 /**
584 * Check access for tier.
585 *
586 * @param int $tier_id Tier id.
587 * @param array $user_abbreviated_subscriptions User subscription abbreviated.
588 *
589 * @return bool
590 */
591 public function maybe_gate_access_for_user_if_tier( $tier_id, $user_abbreviated_subscriptions ) {
592
593 $plan_ids = \Jetpack_Memberships::get_all_newsletter_plan_ids();
594
595 if ( ! in_array( $tier_id, $plan_ids, true ) ) {
596 // If the tier is not in the plans, we bail
597 return false;
598 }
599
600 // We now need the tier price and currency, and the same for the annual price (if available)
601 $all_plans = \Jetpack_Memberships::get_all_plans();
602 $tier = null;
603 foreach ( $all_plans as $post ) {
604 if ( $post->ID === $tier_id ) {
605 $tier = $post;
606 break;
607 }
608 }
609
610 if ( $tier === null ) {
611 return false;
612 }
613
614 $tier_price = self::find_metadata( $tier, 'jetpack_memberships_price' );
615 $tier_currency = self::find_metadata( $tier, 'jetpack_memberships_currency' );
616 $tier_product_id = self::find_metadata( $tier, 'jetpack_memberships_product_id' );
617 $annual_tier_price = $tier_price * 12;
618
619 if ( $tier_price === null || $tier_currency === null || $tier_product_id === null ) {
620 // There is an issue with the meta
621 return false;
622 }
623
624 $tier_price = floatval( $tier_price );
625
626 // At this point we know the post is
627 $annual_tier_id = null;
628 $annual_tier = null;
629 foreach ( $all_plans as $plan ) {
630 if ( intval( self::find_metadata( $plan, 'jetpack_memberships_tier' ) ) === $tier_id ) {
631 $annual_tier = $plan;
632 break;
633 }
634 }
635
636 $annual_tier_price = null;
637 if ( ! empty( $annual_tier ) ) {
638 $annual_tier_id = $annual_tier->ID;
639 $annual_tier_price = floatval( self::find_metadata( $annual_tier, 'jetpack_memberships_price' ) );
640 }
641
642 foreach ( $user_abbreviated_subscriptions as $subscription_plan_id => $details ) {
643 $details = (array) $details;
644
645 if ( ! self::subscription_grants_access( $details ) ) {
646 // Subscription not active anymore (its end_date day has fully passed).
647 continue;
648 }
649
650 $subscription_post = null;
651 foreach ( $all_plans as $plan ) {
652 if ( intval( self::find_metadata( $plan, 'jetpack_memberships_product_id' ) ) === intval( $subscription_plan_id ) ) {
653 $subscription_post = $plan;
654 break;
655 }
656 }
657
658 if ( empty( $subscription_post ) ) {
659 // No post linked to this plan
660 continue;
661 }
662
663 // Comp grants linked to a plan on this site bypass the tier price comparison.
664 if ( ! empty( $details['is_comp'] ) ) {
665 return false;
666 }
667
668 $subscription_post_id = $subscription_post->ID;
669
670 if ( $subscription_post_id === $tier_id || $subscription_post_id === $annual_tier_id ) {
671 // User is subscribed to the right tier
672 return false;
673 }
674
675 $subscription_price = self::find_metadata( $subscription_post, 'jetpack_memberships_price' );
676 $subscription_currency = self::find_metadata( $subscription_post, 'jetpack_memberships_currency' );
677 $subscription_interval = self::find_metadata( $subscription_post, 'jetpack_memberships_interval' );
678
679 if ( $subscription_price === null || $subscription_currency === null || $subscription_interval === null ) {
680 // There is an issue with the meta
681 continue;
682 }
683
684 $subscription_price = floatval( $subscription_price );
685
686 if ( $tier_currency !== $subscription_currency ) {
687 // For now, we don't count if there are different currency (not sure how to convert price in a pure JP env)
688 continue;
689 }
690
691 if ( ( $subscription_interval === '1 month' && $subscription_price >= $tier_price ) ||
692 ( $annual_tier_price !== null && $subscription_interval === '1 year' && $subscription_price >= $annual_tier_price )
693 ) {
694 // One subscription is more expensive than the minimum set by the post' selected tier
695 return false;
696 }
697 }
698 return true; // No user subscription is more expensive than the post's tier price...
699 }
700
701 /**
702 * Decode the given token.
703 *
704 * @param string $token Token to decode.
705 *
706 * @return array|false
707 */
708 public function decode_token( $token ) {
709 if ( empty( $token ) ) {
710 return false;
711 }
712
713 try {
714 $key = $this->get_key();
715 return $key ? (array) JWT::decode( $token, $key, array( 'HS256' ) ) : false;
716 } catch ( \Exception $exception ) {
717 return false;
718 }
719 }
720
721 /**
722 * Get the key for decoding the auth token.
723 *
724 * @return string|false
725 */
726 abstract public function get_key();
727
728 // phpcs:disable
729 /**
730 * Get the URL to access the protected content.
731 *
732 * @param string $mode Access mode (either "subscribe" or "login").
733 */
734 public function access_url( $mode = 'subscribe', $permalink = null ) {
735 global $wp;
736 if ( empty( $permalink ) ) {
737 $permalink = get_permalink();
738 if ( empty( $permalink ) ) {
739 $permalink = add_query_arg( $wp->query_vars, home_url( $wp->request ) );
740 }
741 }
742
743 $login_url = $this->get_rest_api_token_url( $this->get_site_id(), $permalink );
744 return $login_url;
745 }
746 // phpcs:enable
747
748 /**
749 * Get the token stored in the auth cookie.
750 *
751 * @return ?string
752 */
753 private function token_from_cookie() {
754 if ( isset( $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] ) ) {
755 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
756 return $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ];
757 }
758 }
759
760 /**
761 * Check whether the JWT_TOKEN cookie is set
762 *
763 * @return bool
764 */
765 public static function has_token_from_cookie() {
766 return isset( $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] ) && ! empty( $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] );
767 }
768
769 /**
770 * Store the auth cookie.
771 *
772 * Updates `$_COOKIE` in memory so subsequent code in the same request (e.g. another
773 * Premium Content block on the same post) reads the new value and doesn't re-trigger
774 * a refresh against a now-stale value. The Set-Cookie header is emitted via the
775 * standard `setcookie()` — on Atomic / wpcom the response is output-buffered so this
776 * still works during `the_content`; on stricter self-hosted setups the header may
777 * silently be dropped after output starts, in which case the browser keeps the prior
778 * cookie value and the refresh fires again on the next visit (correct degradation).
779 *
780 * @param string $token Auth token.
781 * @return void
782 */
783 private function set_token_cookie( $token ) {
784 if ( empty( $token ) ) {
785 return;
786 }
787
788 $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] = $token;
789
790 if ( defined( 'TESTING_IN_JETPACK' ) && TESTING_IN_JETPACK ) {
791 return;
792 }
793
794 if ( ! headers_sent() ) {
795 // phpcs:ignore Jetpack.Functions.SetCookie.FoundNonHTTPOnlyFalse
796 setcookie( self::JWT_AUTH_TOKEN_COOKIE_NAME, $token, strtotime( '+1 month' ), '/', '', is_ssl(), false );
797 }
798 }
799
800 /**
801 * Clear the auth cookie. Mirrors set_token_cookie(): updates `$_COOKIE` for
802 * in-request consistency, then emits a clearing Set-Cookie header.
803 */
804 public static function clear_token_cookie() {
805 unset( $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] );
806
807 if ( defined( 'TESTING_IN_JETPACK' ) && TESTING_IN_JETPACK ) {
808 return;
809 }
810
811 if ( ! headers_sent() ) {
812 // phpcs:ignore Jetpack.Functions.SetCookie.FoundNonHTTPOnlyFalse
813 setcookie( self::JWT_AUTH_TOKEN_COOKIE_NAME, '', 1, '/', '', is_ssl(), false );
814 }
815 }
816
817 /**
818 * Get the token if present in the current request.
819 *
820 * @return ?string
821 */
822 private function token_from_request() {
823 $token = null;
824 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
825 if ( isset( $_GET['token'] ) && is_string( $_GET['token'] ) ) {
826 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended
827 if ( preg_match( '/^[a-zA-Z0-9\-_]+?\.[a-zA-Z0-9\-_]+?\.([a-zA-Z0-9\-_]+)?$/', $_GET['token'], $matches ) ) {
828 // token matches a valid JWT token pattern.
829 $token = reset( $matches );
830 }
831 }
832 return $token;
833 }
834
835 /**
836 * Return true if an abbreviated subscription currently grants access.
837 *
838 * Access is granted through the end of the subscription's end_date day
839 * (23:59:59 UTC), not the exact end_date timestamp. Memberships store
840 * end_date as the precise purchase timestamp, while billing renews via a
841 * deferred day-0 job that can run several hours after that timestamp. Since
842 * the wider platform already treats end-of-day as the formal expiration
843 * time for subscriptions, expiring at EOD here keeps a same-day auto-renewal
844 * from cutting off access before its renewal completes, and aligns Paid
845 * Content with the rest of WordPress.com / Jetpack.
846 *
847 * Cancelled and otherwise inactive subscriptions never reach this check —
848 * they are dropped by the `'active' === status` filter in
849 * abbreviate_subscriptions() — so this only ever extends an already-active
850 * subscription to the end of its final day.
851 *
852 * @param object|array $subscription Abbreviated subscription (see abbreviate_subscriptions()).
853 *
854 * @return bool
855 */
856 protected static function subscription_grants_access( $subscription ) {
857 $subscription = (array) $subscription;
858 if ( empty( $subscription['end_date'] ) ) {
859 return false;
860 }
861
862 $end = is_int( $subscription['end_date'] ) ? $subscription['end_date'] : strtotime( $subscription['end_date'] );
863 if ( false === $end ) {
864 return false;
865 }
866
867 // Expire at the end of the end_date's day (UTC), matching the platform's
868 // formal expiration convention rather than the exact purchase timestamp.
869 $end_of_day = strtotime( gmdate( 'Y-m-d 23:59:59', $end ) . ' UTC' );
870
871 return $end_of_day >= time();
872 }
873
874 /**
875 * Return true if any ID/date pairs are valid. Otherwise false.
876 *
877 * @param int[] $valid_plan_ids List of valid plan IDs.
878 * @param object[] $token_subscriptions : ID must exist in the provided <code>$valid_subscriptions</code> parameter.
879 * The provided end date needs to fall on or after today,
880 * i.e. access lasts through the end of the end_date day (UTC).
881 *
882 * @return bool
883 */
884 public static function validate_subscriptions( array $valid_plan_ids, array $token_subscriptions ) {
885 // Create a list of product_ids to compare against.
886 $product_ids = array();
887 foreach ( $valid_plan_ids as $plan_id ) {
888 $product_id = (int) get_post_meta( $plan_id, 'jetpack_memberships_product_id', true );
889 if ( isset( $product_id ) ) {
890 $product_ids[] = $product_id;
891 }
892 }
893
894 foreach ( $token_subscriptions as $product_id => $token_subscription ) {
895 if ( in_array( intval( $product_id ), $product_ids, true ) ) {
896 if ( static::subscription_grants_access( $token_subscription ) ) {
897 return true;
898 }
899 }
900 }
901 return false;
902 }
903
904 /**
905 * Get the URL of the JWT endpoint.
906 *
907 * @param int $site_id Site ID.
908 * @param string $redirect_url URL to redirect after checking the token validity.
909 * @return string URL of the JWT endpoint.
910 */
911 private function get_rest_api_token_url( $site_id, $redirect_url ) {
912 // The redirect url might have a part URL encoded but not the whole URL.
913 $redirect_url = rawurldecode( $redirect_url );
914 return sprintf( '%smemberships/jwt?site_id=%d&redirect_url=%s', self::REST_URL_ORIGIN, $site_id, rawurlencode( $redirect_url ) );
915 }
916
917 /**
918 * Report the subscriptions as an ID => [ 'end_date' => ]. mapping
919 *
920 * @param array $subscriptions_from_bd List of subscriptions from BD.
921 *
922 * @return array<int, array>
923 */
924 public static function abbreviate_subscriptions( $subscriptions_from_bd ) {
925
926 if ( empty( $subscriptions_from_bd ) ) {
927 return array();
928 }
929
930 $subscriptions = array();
931 foreach ( $subscriptions_from_bd as $subscription ) {
932 // We are picking the expiry date that is the most in the future.
933 if (
934 'active' === $subscription['status'] && (
935 ! isset( $subscriptions[ $subscription['product_id'] ] ) ||
936 empty( $subscription['end_date'] ) || // Special condition when subscription has no expiry date - we will default to a year from now for the purposes of the token.
937 strtotime( $subscription['end_date'] ) > strtotime( (string) $subscriptions[ $subscription['product_id'] ]->end_date )
938 )
939 ) {
940 $subscriptions[ $subscription['product_id'] ] = new \stdClass();
941 $subscriptions[ $subscription['product_id'] ]->end_date = empty( $subscription['end_date'] ) ? ( time() + 365 * 24 * 3600 ) : $subscription['end_date'];
942 if ( ! empty( $subscription['is_comp'] ) ) {
943 $subscriptions[ $subscription['product_id'] ]->is_comp = true;
944 }
945 }
946 }
947 return $subscriptions;
948 }
949 }
950