Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.52% covered (warning)
68.52%
209 / 305
41.38% covered (danger)
41.38%
12 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
Abstract_Token_Subscription_Service
68.98% covered (warning)
68.98%
209 / 303
41.38% covered (danger)
41.38%
12 / 29
903.37
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%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 maybe_link_wpcom_user_id
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 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
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
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
11
 visitor_has_subscription_access
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 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
11.44
 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.29% covered (warning)
89.29%
50 / 56
0.00% covered (danger)
0.00%
0 / 1
28.96
 decode_token
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 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
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 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
 subscription_grants_access
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 validate_subscriptions
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 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     * 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}