Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
55.11% covered (warning)
55.11%
124 / 225
26.09% covered (danger)
26.09%
6 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
Abstract_Token_Subscription_Service
55.61% covered (warning)
55.61%
124 / 223
26.09% covered (danger)
26.09%
6 / 23
1469.35
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
 get_token_payload
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 get_token_property
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 visitor_can_view_content
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
4
 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
87.72% covered (warning)
87.72%
50 / 57
0.00% covered (danger)
0.00%
0 / 1
30.56
 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
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 has_token_from_cookie
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 set_token_cookie
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
8.12
 clear_token_cookie
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 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     * Get the token payload .
81     *
82     * @return array
83     */
84    public function get_token_payload() {
85        $token = $this->get_and_set_token_from_request();
86        if ( empty( $token ) ) {
87            return array();
88        }
89        $token_payload = $this->decode_token( $token );
90        if ( ! is_array( $token_payload ) ) {
91            return array();
92        }
93        return $token_payload;
94    }
95
96    /**
97     * Get a token property, otherwise return false.
98     *
99     * @param string $key the property name.
100     *
101     * @return mixed|false
102     */
103    public function get_token_property( $key ) {
104        $token_payload = $this->get_token_payload();
105        if ( ! isset( $token_payload[ $key ] ) ) {
106            return false;
107        }
108        return $token_payload[ $key ];
109    }
110
111    /**
112     * The user is visiting with a subscriber token cookie.
113     *
114     * This is theoretically where the cookie JWT signature verification
115     * thing will happen.
116     *
117     * How to obtain one of these (or what exactly it is) is
118     * still a WIP (see api/auth branch)
119     *
120     * @inheritDoc
121     *
122     * @param array $valid_plan_ids List of valid plan IDs.
123     * @param array $access_level Access level for content.
124     *
125     * @return bool Whether the user can view the content
126     */
127    public function visitor_can_view_content( $valid_plan_ids, $access_level ) {
128        global $current_user;
129        $old_user = $current_user; // backup the current user so we can set the current user to the token user for paywall purposes
130
131        $payload        = $this->get_token_payload();
132        $is_valid_token = ! empty( $payload );
133
134        if ( $is_valid_token && isset( $payload['user_id'] ) ) {
135            // set the current user to the payload's user id
136            // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
137            $current_user = get_user_by( 'id', $payload['user_id'] );
138        }
139
140        $is_blog_subscriber = false;
141        $is_paid_subscriber = false;
142        $subscriptions      = array();
143
144        if ( $is_valid_token ) {
145            /**
146             * Allow access to the content if:
147             *
148             * Active: user has a valid subscription
149             */
150            $is_blog_subscriber = in_array(
151                $payload['blog_sub'],
152                array(
153                    self::BLOG_SUB_ACTIVE,
154                ),
155                true
156            );
157            $subscriptions      = (array) $payload['subscriptions'];
158            $is_paid_subscriber = static::validate_subscriptions( $valid_plan_ids, $subscriptions );
159        }
160
161        $has_access = $this->user_has_access( $access_level, $is_blog_subscriber, $is_paid_subscriber, get_the_ID(), $subscriptions );
162        // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
163        $current_user = $old_user;
164        return $has_access;
165    }
166
167    /**
168     * Retrieves the email of the currently authenticated subscriber.
169     *
170     * @return string The email address of the current user.
171     */
172    public function get_subscriber_email() {
173        $email = $this->get_token_property( 'blog_subscriber' );
174        if ( empty( $email ) ) {
175            return '';
176        }
177        return $email;
178    }
179
180    /**
181     * Returns true if the current authenticated user is subscribed to the current site.
182     *
183     * @return boolean
184     */
185    public function is_current_user_subscribed() {
186        return $this->get_token_property( 'blog_sub' ) === 'active';
187    }
188
189    /**
190     * Returns true if the current authenticated user has a pending subscription to the current site.
191     *
192     * @return bool
193     */
194    abstract public function is_current_user_pending_subscriber(): bool;
195
196    /**
197     * Return if the user has access to the content depending on the access level and the user rights
198     *
199     * @param string $access_level Post or blog access level.
200     * @param bool   $is_blog_subscriber Is user a subscriber of the blog.
201     * @param bool   $is_paid_subscriber Is user a paid subscriber of the blog.
202     * @param int    $post_id Post ID.
203     * @param array  $user_abbreviated_subscriptions User subscription abbreviated.
204     *
205     * @return bool Whether the user has access to the content.
206     */
207    protected function user_has_access( $access_level, $is_blog_subscriber, $is_paid_subscriber, $post_id, $user_abbreviated_subscriptions ) {
208
209        if ( is_user_logged_in() && current_user_can( 'edit_post', $post_id ) ) {
210            // Admin has access
211            $has_access = true;
212        } else {
213            switch ( $access_level ) {
214                case self::POST_ACCESS_LEVEL_EVERYBODY:
215                default:
216                    $has_access = true;
217                    break;
218                case self::POST_ACCESS_LEVEL_SUBSCRIBERS:
219                    $has_access = $is_blog_subscriber || $is_paid_subscriber;
220                    break;
221                case self::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS_ALL_TIERS:
222                    $has_access = $is_paid_subscriber;
223                    break;
224                case self::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS:
225                    $has_access = $is_paid_subscriber &&
226                        ! $this->maybe_gate_access_for_user_if_post_tier( $post_id, $user_abbreviated_subscriptions );
227                    break;
228            }
229        }
230
231        do_action( 'earn_user_has_access', $access_level, $has_access, $is_blog_subscriber, $is_paid_subscriber, $post_id );
232        return $has_access;
233    }
234
235    /**
236     * Check post access for tiers.
237     *
238     * @param int   $post_id Current post id.
239     * @param array $user_abbreviated_subscriptions User subscription abbreviated.
240     *
241     * @return bool
242     */
243    private function maybe_gate_access_for_user_if_post_tier( $post_id, $user_abbreviated_subscriptions ) {
244        $tier_id = intval(
245            get_post_meta( $post_id, META_NAME_FOR_POST_TIER_ID_SETTINGS, true )
246        );
247
248        if ( ! $tier_id ) {
249            return false;
250        }
251
252        return $this->maybe_gate_access_for_user_if_tier( $tier_id, $user_abbreviated_subscriptions );
253    }
254
255    /**
256     * Get all plans id that make access valid for a post with this tier id.
257     *
258     * @param int $tier_id Newsletter tier post ID.
259     *
260     * @return array|WP_Error
261     */
262    public static function get_valid_plan_ids_for_tier( int $tier_id ) {
263        // Valid plans are:
264        // - monthly plan with ID $tier_id
265        // - yearly plan related to this $tier_id (in meta jetpack_memberships_tier)
266        // - monthly tiers with same currency and price same or higher than original tier
267        // - yearly plans that are more expensive than the yearly plan linked to the original tier
268
269        $valid_plan_ids = array();
270
271        $all_plans = \Jetpack_Memberships::get_all_plans();
272
273        // Let's get the current tier
274        $tier = null;
275        foreach ( $all_plans as $post ) {
276            if ( $post->ID === $tier_id ) {
277                $tier = $post;
278                break;
279            }
280        }
281
282        if ( $tier === null ) {
283            // We have an error
284            return new WP_Error( 'related-plan-not-found', 'The plan related to the tier cannot be found' );
285        }
286
287        $tier_price      = self::find_metadata( $tier, 'jetpack_memberships_price' );
288        $tier_currency   = self::find_metadata( $tier, 'jetpack_memberships_currency' );
289        $tier_product_id = self::find_metadata( $tier, 'jetpack_memberships_product_id' );
290
291        if ( $tier_price === null || $tier_currency === null || $tier_product_id === null ) {
292            // There is an issue with the meta
293            return new WP_Error( 'wrong-data-plan-not-found', 'The plan related to the tier is missing data' );
294        }
295
296        $valid_plan_ids[] = $tier_id;
297
298        $tier_price = floatval( $tier_price );
299
300        // At this point we know the post is
301        $annual_tier = null;
302        foreach ( $all_plans as $plan ) {
303            if ( intval( self::find_metadata( $plan, 'jetpack_memberships_tier' ) ) === $tier_id ) {
304                $annual_tier = $plan;
305                break;
306            }
307        }
308
309        $annual_tier_price = null;
310        if ( ! empty( $annual_tier ) ) {
311            $annual_tier_price = floatval( self::find_metadata( $annual_tier, 'jetpack_memberships_price' ) );
312            $valid_plan_ids[]  = $annual_tier->ID;
313        }
314
315        foreach ( $all_plans as $post ) {
316            if ( in_array( $post->ID, $valid_plan_ids, true ) ) {
317                continue;
318            }
319
320            $plan_price    = self::find_metadata( $post, 'jetpack_memberships_price' );
321            $plan_currency = self::find_metadata( $post, 'jetpack_memberships_currency' );
322            $plan_interval = self::find_metadata( $post, 'jetpack_memberships_interval' );
323
324            if ( $plan_price === null || $plan_currency === null || $plan_interval === null ) {
325                // There is an issue with the meta
326                continue;
327            }
328
329            $plan_price = floatval( $plan_price );
330
331            if ( $tier_currency !== $plan_currency ) {
332                // For now, we don't count if there are different currency (not sure how to convert price in a pure JP env)
333                continue;
334            }
335
336            if ( ( $plan_interval === '1 month' && $plan_price >= $tier_price ) ||
337                ( $annual_tier_price !== null && $plan_interval === '1 year' && $plan_price >= $annual_tier_price )
338            ) {
339                $valid_plan_ids [] = $post->ID;
340            }
341        }
342
343        return $valid_plan_ids;
344    }
345
346    /**
347     * Find metadata in post
348     *
349     * @param WP_Post|object $post        Post.
350     * @param string         $meta_key    Meta to retrieve.
351     *
352     * @return mixed|null
353     */
354    private static function find_metadata( $post, $meta_key ) {
355
356        if ( $post instanceof WP_Post ) {
357            return $post->{$meta_key};
358        }
359
360        foreach ( $post->metadata as $meta ) {
361            if ( $meta->key === $meta_key ) {
362                return $meta->value;
363            }
364        }
365
366        return null;
367    }
368
369    /**
370     * Check access for tier.
371     *
372     * @param int   $tier_id Tier id.
373     * @param array $user_abbreviated_subscriptions User subscription abbreviated.
374     *
375     * @return bool
376     */
377    public function maybe_gate_access_for_user_if_tier( $tier_id, $user_abbreviated_subscriptions ) {
378
379        $plan_ids = \Jetpack_Memberships::get_all_newsletter_plan_ids();
380
381        if ( ! in_array( $tier_id, $plan_ids, true ) ) {
382            // If the tier is not in the plans, we bail
383            return false;
384        }
385
386        // We now need the tier price and currency, and the same for the annual price (if available)
387        $all_plans = \Jetpack_Memberships::get_all_plans();
388        $tier      = null;
389        foreach ( $all_plans as $post ) {
390            if ( $post->ID === $tier_id ) {
391                $tier = $post;
392                break;
393            }
394        }
395
396        if ( $tier === null ) {
397            return false;
398        }
399
400        $tier_price        = self::find_metadata( $tier, 'jetpack_memberships_price' );
401        $tier_currency     = self::find_metadata( $tier, 'jetpack_memberships_currency' );
402        $tier_product_id   = self::find_metadata( $tier, 'jetpack_memberships_product_id' );
403        $annual_tier_price = $tier_price * 12;
404
405        if ( $tier_price === null || $tier_currency === null || $tier_product_id === null ) {
406            // There is an issue with the meta
407            return false;
408        }
409
410        $tier_price = floatval( $tier_price );
411
412        // At this point we know the post is
413        $annual_tier_id = null;
414        $annual_tier    = null;
415        foreach ( $all_plans as $plan ) {
416            if ( intval( self::find_metadata( $plan, 'jetpack_memberships_tier' ) ) === $tier_id ) {
417                $annual_tier = $plan;
418                break;
419            }
420        }
421
422        $annual_tier_price = null;
423        if ( ! empty( $annual_tier ) ) {
424            $annual_tier_id    = $annual_tier->ID;
425            $annual_tier_price = floatval( self::find_metadata( $annual_tier, 'jetpack_memberships_price' ) );
426        }
427
428        foreach ( $user_abbreviated_subscriptions as $subscription_plan_id => $details ) {
429            $details = (array) $details;
430
431            $end = is_int( $details['end_date'] ) ? $details['end_date'] : strtotime( $details['end_date'] );
432            if ( $end < time() ) {
433                // subscription not active anymore
434                continue;
435            }
436
437            $subscription_post = null;
438            foreach ( $all_plans as $plan ) {
439                if ( intval( self::find_metadata( $plan, 'jetpack_memberships_product_id' ) ) === intval( $subscription_plan_id ) ) {
440                    $subscription_post = $plan;
441                    break;
442                }
443            }
444
445            if ( empty( $subscription_post ) ) {
446                // No post linked to this plan
447                continue;
448            }
449
450            // Comp grants linked to a plan on this site bypass the tier price comparison.
451            if ( ! empty( $details['is_comp'] ) ) {
452                return false;
453            }
454
455            $subscription_post_id = $subscription_post->ID;
456
457            if ( $subscription_post_id === $tier_id || $subscription_post_id === $annual_tier_id ) {
458                // User is subscribed to the right tier
459                return false;
460            }
461
462            $subscription_price    = self::find_metadata( $subscription_post, 'jetpack_memberships_price' );
463            $subscription_currency = self::find_metadata( $subscription_post, 'jetpack_memberships_currency' );
464            $subscription_interval = self::find_metadata( $subscription_post, 'jetpack_memberships_interval' );
465
466            if ( $subscription_price === null || $subscription_currency === null || $subscription_interval === null ) {
467                // There is an issue with the meta
468                continue;
469            }
470
471            $subscription_price = floatval( $subscription_price );
472
473            if ( $tier_currency !== $subscription_currency ) {
474                // For now, we don't count if there are different currency (not sure how to convert price in a pure JP env)
475                continue;
476            }
477
478            if ( ( $subscription_interval === '1 month' && $subscription_price >= $tier_price ) ||
479                    ( $annual_tier_price !== null && $subscription_interval === '1 year' && $subscription_price >= $annual_tier_price )
480            ) {
481                // One subscription is more expensive than the minimum set by the post' selected tier
482                return false;
483            }
484        }
485        return true; // No user subscription is more expensive than the post's tier price...
486    }
487
488    /**
489     * Decode the given token.
490     *
491     * @param string $token Token to decode.
492     *
493     * @return array|false
494     */
495    public function decode_token( $token ) {
496        if ( empty( $token ) ) {
497            return false;
498        }
499
500        try {
501            $key = $this->get_key();
502            return $key ? (array) JWT::decode( $token, $key, array( 'HS256' ) ) : false;
503        } catch ( \Exception $exception ) {
504            return false;
505        }
506    }
507
508    /**
509     * Get the key for decoding the auth token.
510     *
511     * @return string|false
512     */
513    abstract public function get_key();
514
515    // phpcs:disable
516    /**
517     * Get the URL to access the protected content.
518     *
519     * @param string $mode Access mode (either "subscribe" or "login").
520     */
521    public function access_url( $mode = 'subscribe', $permalink = null ) {
522        global $wp;
523        if ( empty( $permalink ) ) {
524            $permalink = get_permalink();
525            if ( empty( $permalink ) ) {
526                $permalink = add_query_arg( $wp->query_vars, home_url( $wp->request ) );
527            }
528        }
529
530        $login_url = $this->get_rest_api_token_url( $this->get_site_id(), $permalink );
531        return $login_url;
532    }
533    // phpcs:enable
534
535    /**
536     * Get the token stored in the auth cookie.
537     *
538     * @return ?string
539     */
540    private function token_from_cookie() {
541        if ( isset( $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] ) ) {
542            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
543            return $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ];
544        }
545    }
546
547    /**
548     * Check whether the JWT_TOKEN cookie is set
549     *
550     * @return bool
551     */
552    public static function has_token_from_cookie() {
553        return isset( $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] ) && ! empty( $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] );
554    }
555
556    /**
557     * Store the auth cookie.
558     *
559     * @param  string $token Auth token.
560     * @return void
561     */
562    private function set_token_cookie( $token ) {
563        if ( defined( 'TESTING_IN_JETPACK' ) && TESTING_IN_JETPACK ) {
564            return;
565        }
566
567        if ( ! empty( $token ) && ! headers_sent() ) {
568            // phpcs:ignore Jetpack.Functions.SetCookie.FoundNonHTTPOnlyFalse
569            setcookie( self::JWT_AUTH_TOKEN_COOKIE_NAME, $token, strtotime( '+1 month' ), '/', '', is_ssl(), false );
570        }
571    }
572
573    /**
574     * Clear the auth cookie.
575     */
576    public static function clear_token_cookie() {
577        if ( defined( 'TESTING_IN_JETPACK' ) && TESTING_IN_JETPACK ) {
578            return;
579        }
580
581        if ( ! self::has_token_from_cookie() ) {
582            return;
583        }
584
585        unset( $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] );
586
587        if ( ! headers_sent() ) {
588            // phpcs:ignore Jetpack.Functions.SetCookie.FoundNonHTTPOnlyFalse
589            setcookie( self::JWT_AUTH_TOKEN_COOKIE_NAME, '', 1, '/', '', is_ssl(), false );
590        }
591    }
592
593    /**
594     * Get the token if present in the current request.
595     *
596     * @return ?string
597     */
598    private function token_from_request() {
599        $token = null;
600        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
601        if ( isset( $_GET['token'] ) && is_string( $_GET['token'] ) ) {
602            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended
603            if ( preg_match( '/^[a-zA-Z0-9\-_]+?\.[a-zA-Z0-9\-_]+?\.([a-zA-Z0-9\-_]+)?$/', $_GET['token'], $matches ) ) {
604                // token matches a valid JWT token pattern.
605                $token = reset( $matches );
606            }
607        }
608        return $token;
609    }
610
611    /**
612     * Return true if any ID/date pairs are valid. Otherwise false.
613     *
614     * @param int[]    $valid_plan_ids List of valid plan IDs.
615     * @param object[] $token_subscriptions : ID must exist in the provided <code>$valid_subscriptions</code> parameter.
616     *                                                            The provided end date needs to be greater than <code>now()</code>.
617     *
618     * @return bool
619     */
620    public static function validate_subscriptions( array $valid_plan_ids, array $token_subscriptions ) {
621        // Create a list of product_ids to compare against.
622        $product_ids = array();
623        foreach ( $valid_plan_ids as $plan_id ) {
624            $product_id = (int) get_post_meta( $plan_id, 'jetpack_memberships_product_id', true );
625            if ( isset( $product_id ) ) {
626                $product_ids[] = $product_id;
627            }
628        }
629
630        foreach ( $token_subscriptions as $product_id => $token_subscription ) {
631            if ( in_array( intval( $product_id ), $product_ids, true ) ) {
632                $end = is_int( $token_subscription->end_date ) ? $token_subscription->end_date : strtotime( $token_subscription->end_date );
633                if ( $end > time() ) {
634                    return true;
635                }
636            }
637        }
638        return false;
639    }
640
641    /**
642     * Get the URL of the JWT endpoint.
643     *
644     * @param  int    $site_id Site ID.
645     * @param  string $redirect_url URL to redirect after checking the token validity.
646     * @return string URL of the JWT endpoint.
647     */
648    private function get_rest_api_token_url( $site_id, $redirect_url ) {
649        // The redirect url might have a part URL encoded but not the whole URL.
650        $redirect_url = rawurldecode( $redirect_url );
651        return sprintf( '%smemberships/jwt?site_id=%d&redirect_url=%s', self::REST_URL_ORIGIN, $site_id, rawurlencode( $redirect_url ) );
652    }
653
654    /**
655     * Report the subscriptions as an ID => [ 'end_date' => ]. mapping
656     *
657     * @param array $subscriptions_from_bd List of subscriptions from BD.
658     *
659     * @return array<int, array>
660     */
661    public static function abbreviate_subscriptions( $subscriptions_from_bd ) {
662
663        if ( empty( $subscriptions_from_bd ) ) {
664            return array();
665        }
666
667        $subscriptions = array();
668        foreach ( $subscriptions_from_bd as $subscription ) {
669            // We are picking the expiry date that is the most in the future.
670            if (
671                'active' === $subscription['status'] && (
672                    ! isset( $subscriptions[ $subscription['product_id'] ] ) ||
673                    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.
674                    strtotime( $subscription['end_date'] ) > strtotime( (string) $subscriptions[ $subscription['product_id'] ]->end_date )
675                )
676            ) {
677                $subscriptions[ $subscription['product_id'] ]           = new \stdClass();
678                $subscriptions[ $subscription['product_id'] ]->end_date = empty( $subscription['end_date'] ) ? ( time() + 365 * 24 * 3600 ) : $subscription['end_date'];
679                if ( ! empty( $subscription['is_comp'] ) ) {
680                    $subscriptions[ $subscription['product_id'] ]->is_comp = true;
681                }
682            }
683        }
684        return $subscriptions;
685    }
686}