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