This commit is contained in:
emmymayo
2025-02-05 23:15:46 +01:00
commit 7269c99357
16995 changed files with 3389680 additions and 0 deletions
@@ -0,0 +1,161 @@
<?php
/**
* Determine access to premium content.
*
* @package Automattic\Jetpack\Extensions\Premium_Content
*/
namespace Automattic\Jetpack\Extensions\Premium_Content;
use Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\Abstract_Token_Subscription_Service;
use Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\WPCOM_Online_Subscription_Service;
use Automattic\Jetpack\Status\Host;
require_once __DIR__ . '/subscription-service/include.php';
/**
* Determines if the memberships module is set up.
*
* @return bool Whether the memberships module is set up.
*/
function membership_checks() {
// If Jetpack is not yet configured, don't show anything ...
if ( ! class_exists( '\Jetpack_Memberships' ) ) {
return false;
}
// if stripe not connected don't show anything...
if ( ! \Jetpack_Memberships::has_connected_account() ) {
return false;
}
return true;
}
/**
* Determines if the site has a plan that supports the
* Premium Content block.
*
* @return bool
*/
function required_plan_checks() {
$availability = \Jetpack_Gutenberg::get_cached_availability();
$slug = 'premium-content/container';
return ( isset( $availability[ $slug ] ) && $availability[ $slug ]['available'] );
}
/**
* Determines if the block should be rendered. Returns true
* if the block passes all required checks, or if the user is
* an editor.
*
* @return bool Whether the block should be rendered.
*/
function pre_render_checks() {
return ( current_user_can_edit() || membership_checks() );
}
/**
* Determines whether the current user can edit.
*
* @return bool Whether the user can edit.
*/
function current_user_can_edit() {
$user = wp_get_current_user();
return 0 !== $user->ID && current_user_can( 'edit_post', get_the_ID() );
}
/**
* Determines if the current user can view the protected content of the given block.
*
* @param array $attributes Block attributes.
* @param object $block Block to check.
*
* @return bool Whether the use can view the content.
*/
function current_visitor_can_access( $attributes, $block ) {
/**
* If the current WordPress install has as signed in user
* they can see the content.
*/
if ( current_user_can_edit() ) {
return true;
}
$selected_plan_ids = array();
if ( isset( $attributes['selectedPlanIds'] ) ) {
$selected_plan_ids = $attributes['selectedPlanIds'];
} elseif ( isset( $attributes['selectedPlanId'] ) ) {
$selected_plan_ids = array( $attributes['selectedPlanId'] );
}
if ( isset( $block ) && ! empty( $block->context['premium-content/planId'] ) ) {
$selected_plan_ids = array( $block->context['premium-content/planId'] );
} elseif ( isset( $block ) && ! empty( $block->context['premium-content/planIds'] ) ) {
$selected_plan_ids = $block->context['premium-content/planIds'];
}
if ( empty( $selected_plan_ids ) ) {
return false;
}
$can_view = false;
$paywall = subscription_service();
$access_level = Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS; // Only paid subscribers should be granted access to the premium content
$tier_ids = \Jetpack_Memberships::get_all_newsletter_plan_ids();
$tier_ids = array_intersect( $tier_ids, $selected_plan_ids );
if ( ! empty( $tier_ids ) ) {
// If the selected plan is a tier, we want to check directly if user has a higher "tier".
// This is to prevent situation where the user upgrades and lose access to premium-gated content
$subscriptions = array();
if ( ( new Host() )->is_wpcom_simple() && is_user_logged_in() ) {
$user_id = wp_get_current_user()->ID;
/**
* Filter the subscriptions attached to a specific user on a given site.
*
* @since 9.4.0
*
* @param array $subscriptions Array of subscriptions.
* @param int $user_id The user's ID.
* @param int $site_id ID of the current site.
*/
$subscriptions = apply_filters( 'earn_get_user_subscriptions_for_site_id', array(), $user_id, get_current_blog_id() );
// format the subscriptions so that they can be validated.
$subscriptions = WPCOM_Online_Subscription_Service::abbreviate_subscriptions( $subscriptions );
} else {
$token = $paywall->get_and_set_token_from_request();
$payload = $paywall->decode_token( $token );
$is_valid_token = ! empty( $payload );
if ( $is_valid_token ) {
$subscriptions = (array) $payload['subscriptions'];
}
}
foreach ( $tier_ids as $tier_id ) {
$can_view = ! $paywall->maybe_gate_access_for_user_if_tier( $tier_id, $subscriptions );
if ( $can_view ) {
break;
}
}
}
$non_tier_ids = array_diff( $selected_plan_ids, $tier_ids );
if ( ! $can_view ) {
// For selected plans that are not tiers, we want to check if the user has any of the selected plans.
$can_view = $paywall->visitor_can_view_content( $non_tier_ids, $access_level );
}
if ( $can_view ) {
/**
* Fires when a visitor can view protected content on a site.
*
* @since 9.4.0
*/
do_action( 'jetpack_earn_remove_cache_headers' );
}
return $can_view;
}
@@ -0,0 +1,62 @@
<?php
/**
* Create legacy buttons markup.
*
* @package Automattic\Jetpack\Extensions\Premium_Content
*/
namespace Automattic\Jetpack\Extensions\Premium_Content;
/**
* Creates a subscribe/login buttons markup for legacy blocks.
*
* @param array $attributes Block attributes.
* @param string $content String containing the block content.
* @param object $block Legacy block.
*
* @return string Subscribe/login buttons markup.
*/
function create_legacy_buttons_markup( $attributes, $content, $block ) {
$button_styles = array();
if ( ! empty( $attributes['customBackgroundButtonColor'] ) ) {
array_push(
$button_styles,
sprintf(
'background-color: %s',
isset( $attributes['customBackgroundButtonColor'] ) ? sanitize_hex_color( $attributes['customBackgroundButtonColor'] ) : 'transparent'
)
);
}
if ( ! empty( $attributes['customTextButtonColor'] ) ) {
array_push(
$button_styles,
sprintf(
'color: %s',
isset( $attributes['customTextButtonColor'] ) ? sanitize_hex_color( $attributes['customTextButtonColor'] ) : 'inherit'
)
);
}
$button_styles = implode( ';', $button_styles );
$login_button = sprintf(
'<div class="wp-block-button"><a role="button" href="%1$s" class="%2$s" style="%3$s">%4$s</a></div>',
subscription_service()->access_url(),
empty( $attributes['buttonClasses'] ) ? 'wp-block-button__link' : esc_attr( $attributes['buttonClasses'] ),
esc_attr( $button_styles ),
empty( $attributes['loginButtonText'] ) ? __( 'Log In', 'jetpack' ) : $attributes['loginButtonText']
);
$subscribe_button = \Jetpack_Memberships::get_instance()->render_button(
array(
'planId' => empty( $block->context['premium-content/planId'] ) ? 0 : $block->context['premium-content/planId'],
'submitButtonClasses' => empty( $attributes['buttonClasses'] ) ? 'wp-block-button__link' : esc_attr( $attributes['buttonClasses'] ),
'customTextButtonColor' => empty( $attributes['customTextButtonColor'] ) ? '' : esc_attr( $attributes['customTextButtonColor'] ),
'customBackgroundButtonColor' => empty( $attributes['customBackgroundButtonColor'] ) ? '' : esc_attr( $attributes['customBackgroundButtonColor'] ),
'submitButtonText' => empty( $attributes['subscribeButtonText'] ) ? __( 'Subscribe', 'jetpack' ) : esc_attr( $attributes['subscribeButtonText'] ),
),
$content,
$block
);
return "<div class='wp-block-premium-content-logged-out-view__buttons'>{$subscribe_button}{$login_button}</div>";
}
@@ -0,0 +1,672 @@
<?php
/**
* A paywall that exchanges JWT tokens from WordPress.com to allow
* a current visitor to view content that has been deemed "Premium content".
*
* @package Automattic\Jetpack\Extensions\Premium_Content
*/
namespace Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service;
use Automattic\Jetpack\Extensions\Premium_Content\JWT;
use WP_Error;
use WP_Post;
use const Automattic\Jetpack\Extensions\Subscriptions\META_NAME_FOR_POST_TIER_ID_SETTINGS;
/**
* Class Abstract_Token_Subscription_Service
*
* @package Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service
*/
abstract class Abstract_Token_Subscription_Service implements Subscription_Service {
const JWT_AUTH_TOKEN_COOKIE_NAME = 'wp-jp-premium-content-session'; // wp prefix helps with skipping batcache
const DECODE_EXCEPTION_FEATURE = 'memberships';
const DECODE_EXCEPTION_MESSAGE = 'Problem decoding provided token';
const REST_URL_ORIGIN = 'https://subscribe.wordpress.com/';
const BLOG_SUB_ACTIVE = 'active';
const BLOG_SUB_PENDING = 'pending';
const POST_ACCESS_LEVEL_EVERYBODY = 'everybody';
const POST_ACCESS_LEVEL_SUBSCRIBERS = 'subscribers';
const POST_ACCESS_LEVEL_PAID_SUBSCRIBERS = 'paid_subscribers';
const POST_ACCESS_LEVEL_PAID_SUBSCRIBERS_ALL_TIERS = 'paid_subscribers_all_tiers';
/**
* An optional user_id to query against (omitting this will use either the token or current user id)
*
* @var int|null
*/
protected $user_id = null;
/**
* Constructor
*
* @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.
*/
public function __construct( $user_id = null ) {
$this->user_id = $user_id;
}
/**
* Initialize the token subscription service.
*
* @inheritDoc
*/
public function initialize() {
$this->get_and_set_token_from_request();
}
/**
* Set the token from the Request to the cookie and retrieve the token.
*
* @return string|null
*/
public function get_and_set_token_from_request() {
// URL token always has a precedence, so it can overwrite the cookie when new data available.
$token = $this->token_from_request();
if ( null !== $token ) {
$this->set_token_cookie( $token );
return $token;
}
return $this->token_from_cookie();
}
/**
* Get the token payload .
*
* @return array
*/
public function get_token_payload() {
$token = $this->get_and_set_token_from_request();
if ( empty( $token ) ) {
return array();
}
$token_payload = $this->decode_token( $token );
if ( ! is_array( $token_payload ) ) {
return array();
}
return $token_payload;
}
/**
* Get a token property, otherwise return false.
*
* @param string $key the property name.
*
* @return mixed|false
*/
public function get_token_property( $key ) {
$token_payload = $this->get_token_payload();
if ( ! isset( $token_payload[ $key ] ) ) {
return false;
}
return $token_payload[ $key ];
}
/**
* The user is visiting with a subscriber token cookie.
*
* This is theoretically where the cookie JWT signature verification
* thing will happen.
*
* How to obtain one of these (or what exactly it is) is
* still a WIP (see api/auth branch)
*
* @inheritDoc
*
* @param array $valid_plan_ids List of valid plan IDs.
* @param array $access_level Access level for content.
*
* @return bool Whether the user can view the content
*/
public function visitor_can_view_content( $valid_plan_ids, $access_level ) {
global $current_user;
$old_user = $current_user; // backup the current user so we can set the current user to the token user for paywall purposes
$payload = $this->get_token_payload();
$is_valid_token = ! empty( $payload );
if ( $is_valid_token && isset( $payload['user_id'] ) ) {
// set the current user to the payload's user id
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$current_user = get_user_by( 'id', $payload['user_id'] );
}
$is_blog_subscriber = false;
$is_paid_subscriber = false;
$subscriptions = array();
if ( $is_valid_token ) {
/**
* Allow access to the content if:
*
* Active: user has a valid subscription
*/
$is_blog_subscriber = in_array(
$payload['blog_sub'],
array(
self::BLOG_SUB_ACTIVE,
),
true
);
$subscriptions = (array) $payload['subscriptions'];
$is_paid_subscriber = static::validate_subscriptions( $valid_plan_ids, $subscriptions );
}
$has_access = $this->user_has_access( $access_level, $is_blog_subscriber, $is_paid_subscriber, get_the_ID(), $subscriptions );
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$current_user = $old_user;
return $has_access;
}
/**
* Retrieves the email of the currently authenticated subscriber.
*
* @return string The email address of the current user.
*/
public function get_subscriber_email() {
$email = $this->get_token_property( 'blog_subscriber' );
if ( empty( $email ) ) {
return '';
}
return $email;
}
/**
* Returns true if the current authenticated user is subscribed to the current site.
*
* @return boolean
*/
public function is_current_user_subscribed() {
return $this->get_token_property( 'blog_sub' ) === 'active';
}
/**
* Returns true if the current authenticated user has a pending subscription to the current site.
*
* @return bool
*/
abstract public function is_current_user_pending_subscriber(): bool;
/**
* Return if the user has access to the content depending on the access level and the user rights
*
* @param string $access_level Post or blog access level.
* @param bool $is_blog_subscriber Is user a subscriber of the blog.
* @param bool $is_paid_subscriber Is user a paid subscriber of the blog.
* @param int $post_id Post ID.
* @param array $user_abbreviated_subscriptions User subscription abbreviated.
*
* @return bool Whether the user has access to the content.
*/
protected function user_has_access( $access_level, $is_blog_subscriber, $is_paid_subscriber, $post_id, $user_abbreviated_subscriptions ) {
if ( is_user_logged_in() && current_user_can( 'edit_post', $post_id ) ) {
// Admin has access
$has_access = true;
} else {
switch ( $access_level ) {
case self::POST_ACCESS_LEVEL_EVERYBODY:
default:
$has_access = true;
break;
case self::POST_ACCESS_LEVEL_SUBSCRIBERS:
$has_access = $is_blog_subscriber || $is_paid_subscriber;
break;
case self::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS_ALL_TIERS:
$has_access = $is_paid_subscriber;
break;
case self::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS:
$has_access = $is_paid_subscriber &&
! $this->maybe_gate_access_for_user_if_post_tier( $post_id, $user_abbreviated_subscriptions );
break;
}
}
do_action( 'earn_user_has_access', $access_level, $has_access, $is_blog_subscriber, $is_paid_subscriber, $post_id );
return $has_access;
}
/**
* Check post access for tiers.
*
* @param int $post_id Current post id.
* @param array $user_abbreviated_subscriptions User subscription abbreviated.
*
* @return bool
*/
private function maybe_gate_access_for_user_if_post_tier( $post_id, $user_abbreviated_subscriptions ) {
$tier_id = intval(
get_post_meta( $post_id, META_NAME_FOR_POST_TIER_ID_SETTINGS, true )
);
if ( ! $tier_id ) {
return false;
}
return $this->maybe_gate_access_for_user_if_tier( $tier_id, $user_abbreviated_subscriptions );
}
/**
* Get all plans id that make access valid for a post with this tier id.
*
* @param int $tier_id Newsletter tier post ID.
*
* @return array|WP_Error
*/
public static function get_valid_plan_ids_for_tier( int $tier_id ) {
// Valid plans are:
// - monthly plan with ID $tier_id
// - yearly plan related to this $tier_id (in meta jetpack_memberships_tier)
// - monthly tiers with same currency and price same or higher than original tier
// - yearly plans that are more expensive than the yearly plan linked to the original tier
$valid_plan_ids = array();
$all_plans = \Jetpack_Memberships::get_all_plans();
// Let's get the current tier
$tier = null;
foreach ( $all_plans as $post ) {
if ( $post->ID === $tier_id ) {
$tier = $post;
break;
}
}
if ( $tier === null ) {
// We have an error
return new WP_Error( 'related-plan-not-found', 'The plan related to the tier cannot be found' );
}
$tier_price = self::find_metadata( $tier, 'jetpack_memberships_price' );
$tier_currency = self::find_metadata( $tier, 'jetpack_memberships_currency' );
$tier_product_id = self::find_metadata( $tier, 'jetpack_memberships_product_id' );
if ( $tier_price === null || $tier_currency === null || $tier_product_id === null ) {
// There is an issue with the meta
return new WP_Error( 'wrong-data-plan-not-found', 'The plan related to the tier is missing data' );
}
$valid_plan_ids[] = $tier_id;
$tier_price = floatval( $tier_price );
// At this point we know the post is
$annual_tier = null;
foreach ( $all_plans as $plan ) {
if ( intval( self::find_metadata( $plan, 'jetpack_memberships_tier' ) ) === $tier_id ) {
$annual_tier = $plan;
break;
}
}
$annual_tier_price = null;
if ( ! empty( $annual_tier ) ) {
$annual_tier_price = floatval( self::find_metadata( $annual_tier, 'jetpack_memberships_price' ) );
$valid_plan_ids[] = $annual_tier->ID;
}
foreach ( $all_plans as $post ) {
if ( in_array( $post->ID, $valid_plan_ids, true ) ) {
continue;
}
$plan_price = self::find_metadata( $post, 'jetpack_memberships_price' );
$plan_currency = self::find_metadata( $post, 'jetpack_memberships_currency' );
$plan_interval = self::find_metadata( $post, 'jetpack_memberships_interval' );
if ( $plan_price === null || $plan_currency === null || $plan_interval === null ) {
// There is an issue with the meta
continue;
}
$plan_price = floatval( $plan_price );
if ( $tier_currency !== $plan_currency ) {
// For now, we don't count if there are different currency (not sure how to convert price in a pure JP env)
continue;
}
if ( ( $plan_interval === '1 month' && $plan_price >= $tier_price ) ||
( $annual_tier_price !== null && $plan_interval === '1 year' && $plan_price >= $annual_tier_price )
) {
$valid_plan_ids [] = $post->ID;
}
}
return $valid_plan_ids;
}
/**
* Find metadata in post
*
* @param WP_Post|object $post Post.
* @param string $meta_key Meta to retrieve.
*
* @return mixed|null
*/
private static function find_metadata( $post, $meta_key ) {
if ( $post instanceof WP_Post ) {
return $post->{$meta_key};
}
foreach ( $post->metadata as $meta ) {
if ( $meta->key === $meta_key ) {
return $meta->value;
}
}
return null;
}
/**
* Check access for tier.
*
* @param int $tier_id Tier id.
* @param array $user_abbreviated_subscriptions User subscription abbreviated.
*
* @return bool
*/
public function maybe_gate_access_for_user_if_tier( $tier_id, $user_abbreviated_subscriptions ) {
$plan_ids = \Jetpack_Memberships::get_all_newsletter_plan_ids();
if ( ! in_array( $tier_id, $plan_ids, true ) ) {
// If the tier is not in the plans, we bail
return false;
}
// We now need the tier price and currency, and the same for the annual price (if available)
$all_plans = \Jetpack_Memberships::get_all_plans();
$tier = null;
foreach ( $all_plans as $post ) {
if ( $post->ID === $tier_id ) {
$tier = $post;
break;
}
}
if ( $tier === null ) {
return false;
}
$tier_price = self::find_metadata( $tier, 'jetpack_memberships_price' );
$tier_currency = self::find_metadata( $tier, 'jetpack_memberships_currency' );
$tier_product_id = self::find_metadata( $tier, 'jetpack_memberships_product_id' );
$annual_tier_price = $tier_price * 12;
if ( $tier_price === null || $tier_currency === null || $tier_product_id === null ) {
// There is an issue with the meta
return false;
}
$tier_price = floatval( $tier_price );
// At this point we know the post is
$annual_tier_id = null;
$annual_tier = null;
foreach ( $all_plans as $plan ) {
if ( intval( self::find_metadata( $plan, 'jetpack_memberships_tier' ) ) === $tier_id ) {
$annual_tier = $plan;
break;
}
}
$annual_tier_price = null;
if ( ! empty( $annual_tier ) ) {
$annual_tier_id = $annual_tier->ID;
$annual_tier_price = floatval( self::find_metadata( $annual_tier, 'jetpack_memberships_price' ) );
}
foreach ( $user_abbreviated_subscriptions as $subscription_plan_id => $details ) {
$details = (array) $details;
$end = is_int( $details['end_date'] ) ? $details['end_date'] : strtotime( $details['end_date'] );
if ( $end < time() ) {
// subscription not active anymore
continue;
}
$subscription_post = null;
foreach ( $all_plans as $plan ) {
if ( intval( self::find_metadata( $plan, 'jetpack_memberships_product_id' ) ) === intval( $subscription_plan_id ) ) {
$subscription_post = $plan;
break;
}
}
if ( empty( $subscription_post ) ) {
// No post linked to this plan
continue;
}
$subscription_post_id = $subscription_post->ID;
if ( $subscription_post_id === $tier_id || $subscription_post_id === $annual_tier_id ) {
// User is subscribed to the right tier
return false;
}
$subscription_price = self::find_metadata( $subscription_post, 'jetpack_memberships_price' );
$subscription_currency = self::find_metadata( $subscription_post, 'jetpack_memberships_currency' );
$subscription_interval = self::find_metadata( $subscription_post, 'jetpack_memberships_interval' );
if ( $subscription_price === null || $subscription_currency === null || $subscription_interval === null ) {
// There is an issue with the meta
continue;
}
$subscription_price = floatval( $subscription_price );
if ( $tier_currency !== $subscription_currency ) {
// For now, we don't count if there are different currency (not sure how to convert price in a pure JP env)
continue;
}
if ( ( $subscription_interval === '1 month' && $subscription_price >= $tier_price ) ||
( $annual_tier_price !== null && $subscription_interval === '1 year' && $subscription_price >= $annual_tier_price )
) {
// One subscription is more expensive than the minimum set by the post' selected tier
return false;
}
}
return true; // No user subscription is more expensive than the post's tier price...
}
/**
* Decode the given token.
*
* @param string $token Token to decode.
*
* @return array|false
*/
public function decode_token( $token ) {
if ( empty( $token ) ) {
return false;
}
try {
$key = $this->get_key();
return $key ? (array) JWT::decode( $token, $key, array( 'HS256' ) ) : false;
} catch ( \Exception $exception ) {
return false;
}
}
/**
* Get the key for decoding the auth token.
*
* @return string|false
*/
abstract public function get_key();
// phpcs:disable
/**
* Get the URL to access the protected content.
*
* @param string $mode Access mode (either "subscribe" or "login").
*/
public function access_url( $mode = 'subscribe', $permalink = null ) {
global $wp;
if ( empty( $permalink ) ) {
$permalink = get_permalink();
if ( empty( $permalink ) ) {
$permalink = add_query_arg( $wp->query_vars, home_url( $wp->request ) );
}
}
$login_url = $this->get_rest_api_token_url( $this->get_site_id(), $permalink );
return $login_url;
}
// phpcs:enable
/**
* Get the token stored in the auth cookie.
*
* @return ?string
*/
private function token_from_cookie() {
if ( isset( $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] ) ) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
return $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ];
}
}
/**
* Check whether the JWT_TOKEN cookie is set
*
* @return bool
*/
public static function has_token_from_cookie() {
return isset( $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] ) && ! empty( $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] );
}
/**
* Store the auth cookie.
*
* @param string $token Auth token.
* @return void
*/
private function set_token_cookie( $token ) {
if ( defined( 'TESTING_IN_JETPACK' ) && TESTING_IN_JETPACK ) {
return;
}
if ( ! empty( $token ) && ! headers_sent() ) {
// phpcs:ignore Jetpack.Functions.SetCookie.FoundNonHTTPOnlyFalse
setcookie( self::JWT_AUTH_TOKEN_COOKIE_NAME, $token, strtotime( '+1 month' ), '/', '', is_ssl(), false );
}
}
/**
* Clear the auth cookie.
*/
public static function clear_token_cookie() {
if ( defined( 'TESTING_IN_JETPACK' ) && TESTING_IN_JETPACK ) {
return;
}
if ( ! self::has_token_from_cookie() ) {
return;
}
unset( $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] );
if ( ! headers_sent() ) {
// phpcs:ignore Jetpack.Functions.SetCookie.FoundNonHTTPOnlyFalse
setcookie( self::JWT_AUTH_TOKEN_COOKIE_NAME, '', 1, '/', '', is_ssl(), false );
}
}
/**
* Get the token if present in the current request.
*
* @return ?string
*/
private function token_from_request() {
$token = null;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['token'] ) && is_string( $_GET['token'] ) ) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended
if ( preg_match( '/^[a-zA-Z0-9\-_]+?\.[a-zA-Z0-9\-_]+?\.([a-zA-Z0-9\-_]+)?$/', $_GET['token'], $matches ) ) {
// token matches a valid JWT token pattern.
$token = reset( $matches );
}
}
return $token;
}
/**
* Return true if any ID/date pairs are valid. Otherwise false.
*
* @param int[] $valid_plan_ids List of valid plan IDs.
* @param object[] $token_subscriptions : ID must exist in the provided <code>$valid_subscriptions</code> parameter.
* The provided end date needs to be greater than <code>now()</code>.
*
* @return bool
*/
public static function validate_subscriptions( array $valid_plan_ids, array $token_subscriptions ) {
// Create a list of product_ids to compare against.
$product_ids = array();
foreach ( $valid_plan_ids as $plan_id ) {
$product_id = (int) get_post_meta( $plan_id, 'jetpack_memberships_product_id', true );
if ( isset( $product_id ) ) {
$product_ids[] = $product_id;
}
}
foreach ( $token_subscriptions as $product_id => $token_subscription ) {
if ( in_array( intval( $product_id ), $product_ids, true ) ) {
$end = is_int( $token_subscription->end_date ) ? $token_subscription->end_date : strtotime( $token_subscription->end_date );
if ( $end > time() ) {
return true;
}
}
}
return false;
}
/**
* Get the URL of the JWT endpoint.
*
* @param int $site_id Site ID.
* @param string $redirect_url URL to redirect after checking the token validity.
* @return string URL of the JWT endpoint.
*/
private function get_rest_api_token_url( $site_id, $redirect_url ) {
// The redirect url might have a part URL encoded but not the whole URL.
$redirect_url = rawurldecode( $redirect_url );
return sprintf( '%smemberships/jwt?site_id=%d&redirect_url=%s', self::REST_URL_ORIGIN, $site_id, rawurlencode( $redirect_url ) );
}
/**
* Report the subscriptions as an ID => [ 'end_date' => ]. mapping
*
* @param array $subscriptions_from_bd List of subscriptions from BD.
*
* @return array<int, array>
*/
public static function abbreviate_subscriptions( $subscriptions_from_bd ) {
if ( empty( $subscriptions_from_bd ) ) {
return array();
}
$subscriptions = array();
foreach ( $subscriptions_from_bd as $subscription ) {
// We are picking the expiry date that is the most in the future.
if (
'active' === $subscription['status'] && (
! isset( $subscriptions[ $subscription['product_id'] ] ) ||
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.
strtotime( $subscription['end_date'] ) > strtotime( (string) $subscriptions[ $subscription['product_id'] ]->end_date )
)
) {
$subscriptions[ $subscription['product_id'] ] = new \stdClass();
$subscriptions[ $subscription['product_id'] ]->end_date = empty( $subscription['end_date'] ) ? ( time() + 365 * 24 * 3600 ) : $subscription['end_date'];
}
}
return $subscriptions;
}
}
@@ -0,0 +1,65 @@
<?php
/**
* A paywall that exchanges JWT tokens from WordPress.com to allow
* a current visitor to view content that has been deemed "Premium content".
*
* @package Automattic\Jetpack\Extensions\Premium_Content
*/
namespace Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service;
use Automattic\Jetpack\Connection\Tokens;
use Automattic\Jetpack\Status\Host;
/**
* Class Jetpack_Token_Subscription_Service
*
* @package Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service
*/
class Jetpack_Token_Subscription_Service extends Abstract_Token_Subscription_Service {
/**
* Is the Jetpack_Options class available?
*
* @return bool Whether Jetpack_Options class exists.
*/
public static function available() {
return ( new Host() )->is_wpcom_simple() || class_exists( '\Jetpack_Options' );
}
/**
* Get the site ID.
*
* @return int The site ID.
*/
public function get_site_id() {
return \Jetpack_Options::get_option( 'id' );
}
/**
* Get the key.
*
* @return string The key.
*/
public function get_key() {
if ( ( new Host() )->is_wpcom_simple() ) {
// phpcs:ignore ImportDetection.Imports.RequireImports.Symbol
return defined( 'EARN_JWT_SIGNING_KEY' ) ? EARN_JWT_SIGNING_KEY : false;
}
$token = ( new Tokens() )->get_access_token();
if ( ! isset( $token->secret ) ) {
return false;
}
return $token->secret;
}
/**
* Returns true if the current authenticated user has a pending subscription to the current site.
*
* @return bool
*/
public function is_current_user_pending_subscriber(): bool {
return self::BLOG_SUB_PENDING === $this->get_token_property( 'blog_sub' );
}
}
@@ -0,0 +1,426 @@
<?php
/**
* JSON Web Token implementation, based on this spec:
* https://tools.ietf.org/html/rfc7519
*
* @package Automattic\Jetpack\Extensions\Premium_Content
*/
namespace Automattic\Jetpack\Extensions\Premium_Content;
use DateTime;
use DomainException;
use InvalidArgumentException;
use UnexpectedValueException;
/**
* JSON Web Token implementation, based on this spec:
* https://tools.ietf.org/html/rfc7519
*
* PHP version 5
*
* @category Authentication
* @package Authentication_JWT
* @author Neuman Vong <neuman@twilio.com>
* @author Anant Narayanan <anant@php.net>
* @license http://opensource.org/licenses/BSD-3-Clause 3-clause BSD
* @link https://github.com/firebase/php-jwt
*/
class JWT {
/**
* When checking nbf, iat or expiration times,
* we want to provide some extra leeway time to
* account for clock skew.
*
* @var int $leeway The leeway value.
*/
public static $leeway = 0;
/**
* Allow the current timestamp to be specified.
* Useful for fixing a value within unit testing.
*
* Will default to PHP time() value if null.
*
* @var string $timestamp The timestamp.
*/
public static $timestamp = null;
/**
* Supported algorithms.
*
* @var array $supported_algs Supported algorithms.
*/
public static $supported_algs = array(
'HS256' => array( 'hash_hmac', 'SHA256' ),
'HS512' => array( 'hash_hmac', 'SHA512' ),
'HS384' => array( 'hash_hmac', 'SHA384' ),
'RS256' => array( 'openssl', 'SHA256' ),
'RS384' => array( 'openssl', 'SHA384' ),
'RS512' => array( 'openssl', 'SHA512' ),
);
/**
* Decodes a JWT string into a PHP object.
*
* @param string $jwt The JWT.
* @param string|array $key The key, or map of keys.
* If the algorithm used is asymmetric, this is the public key.
* @param array $allowed_algs List of supported verification algorithms.
* Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256'.
*
* @return object The JWT's payload as a PHP object
*
* @throws UnexpectedValueException Provided JWT was invalid.
* @throws SignatureInvalidException Provided JWT was invalid because the signature verification failed.
* @throws InvalidArgumentException Provided JWT is trying to be used before it's eligible as defined by 'nbf'.
* @throws BeforeValidException Provided JWT is trying to be used before it's been created as defined by 'iat'.
* @throws ExpiredException Provided JWT has since expired, as defined by the 'exp' claim.
*
* @uses json_decode
* @uses urlsafe_b64_decode
*/
public static function decode( $jwt, $key, array $allowed_algs = array() ) {
$timestamp = static::$timestamp === null ? time() : static::$timestamp;
if ( empty( $key ) ) {
throw new InvalidArgumentException( 'Key may not be empty' );
}
$tks = explode( '.', $jwt );
if ( count( $tks ) !== 3 ) {
throw new UnexpectedValueException( 'Wrong number of segments' );
}
list( $headb64, $bodyb64, $cryptob64 ) = $tks;
$header = static::json_decode( static::urlsafe_b64_decode( $headb64 ) );
if ( null === $header ) {
throw new UnexpectedValueException( 'Invalid header encoding' );
}
$payload = static::json_decode( static::urlsafe_b64_decode( $bodyb64 ) );
if ( null === $payload ) {
throw new UnexpectedValueException( 'Invalid claims encoding' );
}
$sig = static::urlsafe_b64_decode( $cryptob64 );
if ( false === $sig ) {
throw new UnexpectedValueException( 'Invalid signature encoding' );
}
if ( empty( $header->alg ) ) {
throw new UnexpectedValueException( 'Empty algorithm' );
}
if ( empty( static::$supported_algs[ $header->alg ] ) ) {
throw new UnexpectedValueException( 'Algorithm not supported' );
}
if ( ! in_array( $header->alg, $allowed_algs, true ) ) {
throw new UnexpectedValueException( 'Algorithm not allowed' );
}
if ( is_array( $key ) || $key instanceof \ArrayAccess ) {
if ( isset( $header->kid ) ) {
if ( ! isset( $key[ $header->kid ] ) ) {
throw new UnexpectedValueException( '"kid" invalid, unable to lookup correct key' );
}
$key = $key[ $header->kid ];
} else {
throw new UnexpectedValueException( '"kid" empty, unable to lookup correct key' );
}
}
// Check the signature.
if ( ! static::verify( "$headb64.$bodyb64", $sig, $key, $header->alg ) ) {
throw new SignatureInvalidException( 'Signature verification failed' );
}
// Check if the nbf if it is defined. This is the time that the
// token can actually be used. If it's not yet that time, abort.
if ( isset( $payload->nbf ) && $payload->nbf > ( $timestamp + static::$leeway ) ) {
throw new BeforeValidException(
'Cannot handle token prior to ' . gmdate( DateTime::ISO8601, $payload->nbf )
);
}
// Check that this token has been created before 'now'. This prevents
// using tokens that have been created for later use (and haven't
// correctly used the nbf claim).
if ( isset( $payload->iat ) && $payload->iat > ( $timestamp + static::$leeway ) ) {
throw new BeforeValidException(
'Cannot handle token prior to ' . gmdate( DateTime::ISO8601, $payload->iat )
);
}
// Check if this token has expired.
if ( isset( $payload->exp ) && ( $timestamp - static::$leeway ) >= $payload->exp ) {
throw new ExpiredException( 'Expired token' );
}
return $payload;
}
/**
* Converts and signs a PHP object or array into a JWT string.
*
* @param object|array $payload PHP object or array.
* @param string $key The secret key.
* If the algorithm used is asymmetric, this is the private key.
* @param string $alg The signing algorithm.
* Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256'.
* @param mixed $key_id The key ID.
* @param array $head An array with header elements to attach.
*
* @return string A signed JWT
*
* @uses json_encode
* @uses urlsafe_b64_decode
*/
public static function encode( $payload, $key, $alg = 'HS256', $key_id = null, $head = null ) {
$header = array(
'typ' => 'JWT',
'alg' => $alg,
);
if ( null !== $key_id ) {
$header['kid'] = $key_id;
}
if ( isset( $head ) && is_array( $head ) ) {
$header = array_merge( $head, $header );
}
$segments = array();
$segments[] = static::urlsafe_b64_encode( static::json_encode( $header ) );
$segments[] = static::urlsafe_b64_encode( static::json_encode( $payload ) );
$signing_input = implode( '.', $segments );
$signature = static::sign( $signing_input, $key, $alg );
$segments[] = static::urlsafe_b64_encode( $signature );
return implode( '.', $segments );
}
/**
* Sign a string with a given key and algorithm.
*
* @param string $msg The message to sign.
* @param string|resource $key The secret key.
* @param string $alg The signing algorithm.
* Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256'.
*
* @return string An encrypted message
*
* @throws DomainException Unsupported algorithm was specified.
*/
public static function sign( $msg, $key, $alg = 'HS256' ) {
if ( empty( static::$supported_algs[ $alg ] ) ) {
throw new DomainException( 'Algorithm not supported' );
}
list($function, $algorithm) = static::$supported_algs[ $alg ];
switch ( $function ) {
case 'hash_hmac':
return hash_hmac( $algorithm, $msg, $key, true );
case 'openssl':
$signature = '';
$success = openssl_sign( $msg, $signature, $key, $algorithm );
if ( ! $success ) {
throw new DomainException( 'OpenSSL unable to sign data' );
} else {
return $signature;
}
}
}
/**
* Verify a signature with the message, key and method. Not all methods
* are symmetric, so we must have a separate verify and sign method.
*
* @param string $msg The original message (header and body).
* @param string $signature The original signature.
* @param string|resource $key For HS*, a string key works. for RS*, must be a resource of an openssl public key.
* @param string $alg The algorithm.
*
* @return bool
*
* @throws DomainException Invalid Algorithm or OpenSSL failure.
*/
private static function verify( $msg, $signature, $key, $alg ) {
if ( empty( static::$supported_algs[ $alg ] ) ) {
throw new DomainException( 'Algorithm not supported' );
}
list($function, $algorithm) = static::$supported_algs[ $alg ];
switch ( $function ) {
case 'openssl':
$success = openssl_verify( $msg, $signature, $key, $algorithm );
if ( 1 === $success ) {
return true;
} elseif ( 0 === $success ) {
return false;
}
// returns 1 on success, 0 on failure, -1 on error.
throw new DomainException(
'OpenSSL error: ' . openssl_error_string()
);
case 'hash_hmac':
default:
$hash = hash_hmac( $algorithm, $msg, $key, true );
if ( function_exists( 'hash_equals' ) ) {
return hash_equals( $signature, $hash );
}
$len = min( static::safe_strlen( $signature ), static::safe_strlen( $hash ) );
$status = 0;
for ( $i = 0; $i < $len; $i++ ) {
$status |= ( ord( $signature[ $i ] ) ^ ord( $hash[ $i ] ) );
}
$status |= ( static::safe_strlen( $signature ) ^ static::safe_strlen( $hash ) );
return ( 0 === $status );
}
}
/**
* Decode a JSON string into a PHP object.
*
* @param string $input JSON string.
*
* @return object Object representation of JSON string
*
* @throws DomainException Provided string was invalid JSON.
*/
public static function json_decode( $input ) {
$obj = json_decode( $input, false, 512, JSON_BIGINT_AS_STRING );
$errno = json_last_error();
if ( $errno ) {
static::handle_json_error( $errno );
} elseif ( null === $obj && 'null' !== $input ) {
throw new DomainException( 'Null result with non-null input' );
}
return $obj;
}
/**
* Encode a PHP object into a JSON string.
*
* @param object|array $input A PHP object or array.
*
* @return string JSON representation of the PHP object or array.
*
* @throws DomainException Provided object could not be encoded to valid JSON.
*/
public static function json_encode( $input ) {
$json = wp_json_encode( $input );
$errno = json_last_error();
if ( $errno ) {
static::handle_json_error( $errno );
} elseif ( 'null' === $json && null !== $input ) {
throw new DomainException( 'Null result with non-null input' );
}
return $json;
}
/**
* Decode a string with URL-safe Base64.
*
* @param string $input A Base64 encoded string.
*
* @return string A decoded string
*/
public static function urlsafe_b64_decode( $input ) {
$remainder = strlen( $input ) % 4;
if ( $remainder ) {
$padlen = 4 - $remainder;
$input .= str_repeat( '=', $padlen );
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
return base64_decode( strtr( $input, '-_', '+/' ) );
}
/**
* Encode a string with URL-safe Base64.
*
* @param string $input The string you want encoded.
*
* @return string The base64 encode of what you passed in
*/
public static function urlsafe_b64_encode( $input ) {
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
return str_replace( '=', '', strtr( base64_encode( $input ), '+/', '-_' ) );
}
/**
* Helper method to create a JSON error.
*
* @param int $errno An error number from json_last_error().
* @throws DomainException .
*
* @return never
*/
private static function handle_json_error( $errno ) {
$messages = array(
JSON_ERROR_DEPTH => 'Maximum stack depth exceeded',
JSON_ERROR_STATE_MISMATCH => 'Invalid or malformed JSON',
JSON_ERROR_CTRL_CHAR => 'Unexpected control character found',
JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON',
JSON_ERROR_UTF8 => 'Malformed UTF-8 characters',
);
throw new DomainException(
isset( $messages[ $errno ] )
? $messages[ $errno ]
: 'Unknown JSON error: ' . $errno
);
}
/**
* Get the number of bytes in cryptographic strings.
*
* @param string $str .
*
* @return int
*/
private static function safe_strlen( $str ) {
if ( function_exists( 'mb_strlen' ) ) {
return mb_strlen( $str, '8bit' );
}
return strlen( $str );
}
}
// phpcs:disable
if ( ! class_exists( 'SignatureInvalidException' ) ) {
/**
* SignatureInvalidException
*
* @package Automattic\Jetpack\Extensions\Premium_Content
*/
class SignatureInvalidException extends \UnexpectedValueException { }
}
if ( ! class_exists( 'ExpiredException' ) ) {
/**
* ExpiredException
*
* @package Automattic\Jetpack\Extensions\Premium_Content
*/
class ExpiredException extends \UnexpectedValueException { }
}
if ( ! class_exists( 'BeforeValidException' ) ) {
/**
* BeforeValidException
*
* @package Automattic\Jetpack\Extensions\Premium_Content
*/
class BeforeValidException extends \UnexpectedValueException { }
}
// phpcs:enable
@@ -0,0 +1,69 @@
<?php
/**
* The environment does not have a subscription service available.
* This represents this scenario.
*
* @package Automattic\Jetpack\Extensions\Premium_Content
*/
namespace Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service;
use function site_url;
// phpcs:disable
/**
* Class Unconfigured_Subscription_Service
*
* @package Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service
*/
class Unconfigured_Subscription_Service implements Subscription_Service {
/**
* Is always available because it is the fallback.
*
* @inheritDoc
*/
public static function available() {
return true;
}
/**
* Function: initialize()
*
* @inheritDoc
*/
public function initialize() {
// noop.
}
/**
* No subscription service available, no users can see this content.
*
* @param array $valid_plan_ids .
* @param string $access_level .
*/
public function visitor_can_view_content( $valid_plan_ids, $access_level ) {
return false;
}
/**
* is the current user a pending subscriber for the current site?
*
* @return bool
*/
public function is_current_user_pending_subscriber(): bool
{
return false;
}
/**
* The current visitor would like to obtain access. Where do they go?
*
* @param string $mode .
*/
public function access_url( $mode = 'subscribe' ) {
return site_url();
}
}
// phpcs:enable
@@ -0,0 +1,66 @@
<?php
/**
* This subscription service is used when a subscriber is offline and a token is not available.
*
* @package Automattic\Jetpack\Extensions\Premium_Content
*/
namespace Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service;
use const Automattic\Jetpack\Extensions\Subscriptions\META_NAME_FOR_POST_LEVEL_ACCESS_SETTINGS;
/**
* Class WPCOM_Offline_Subscription_Service
* This subscription service is used when a subscriber is offline and a token is not available.
* This subscription service will be used when sending emails to subscribers
*
* @package Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service
*/
class WPCOM_Offline_Subscription_Service extends WPCOM_Online_Subscription_Service {
/**
* Is available()
*
* @return bool
*/
public static function available() {
// Return available if the user is logged in and we are on WPCOM.
return false;
}
/**
* Check if the subscriber can receive the newsletter.
* This is the only method where is user does not need to be logged in.
*
* @param int $user_id User id.
* @param int $post_id Post id.
*
* @return bool
* @throws \Exception Throws an exception when used outside of WPCOM.
*/
public function subscriber_can_receive_post_by_mail( $user_id, $post_id ) {
if ( 0 === $user_id || empty( $user_id ) ) {
// Email cannot be sent to non-users
return false;
}
$previous_user = wp_get_current_user();
wp_set_current_user( $user_id );
$access_level = get_post_meta( $post_id, META_NAME_FOR_POST_LEVEL_ACCESS_SETTINGS, true );
if ( ! $access_level || self::POST_ACCESS_LEVEL_EVERYBODY === $access_level ) {
// The post is not gated, we return early
return true;
}
$valid_plan_ids = \Jetpack_Memberships::get_all_newsletter_plan_ids();
$is_blog_subscriber = true; // it is a subscriber as this is used in async when lopping through subscribers...
$allowed = $this->user_can_view_content( $valid_plan_ids, $access_level, $is_blog_subscriber, $post_id );
wp_set_current_user( $previous_user->ID );
return $allowed;
}
}
@@ -0,0 +1,167 @@
<?php
/**
* This subscription service is used when a subscriber is online
*
* @package Automattic\Jetpack\Extensions\Premium_Content
*/
namespace Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service;
/**
* Class WPCOM_Offline_Subscription_Service
*
* @package Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service
*/
class WPCOM_Online_Subscription_Service extends Jetpack_Token_Subscription_Service {
/**
* Is available()
*
* @return bool
*/
public static function available() {
// Return available if the user is logged in and we are on WPCOM.
return defined( 'IS_WPCOM' ) && IS_WPCOM && is_user_logged_in();
}
/**
* Lookup users subscriptions for a site and determine if the user has a valid subscription to match the plan ID
*
* @param array $valid_plan_ids .
* @param string $access_level .
*
* @return bool
*/
public function visitor_can_view_content( $valid_plan_ids, $access_level ) {
include_once WP_CONTENT_DIR . '/mu-plugins/email-subscriptions/subscriptions.php';
$email = wp_get_current_user()->user_email;
$subscriber_object = \Blog_Subscriber::get( $email );
$is_blog_subscriber = false;
if ( $subscriber_object ) {
$blog_id = $this->get_site_id();
$subscription_status = \Blog_Subscription::get_subscription_status_for_blog( $subscriber_object, $blog_id );
$is_blog_subscriber = 'active' === $subscription_status;
}
return $this->user_can_view_content( $valid_plan_ids, $access_level, $is_blog_subscriber, get_the_ID() );
}
/**
* Retrieves the email of the currently authenticated subscriber.
*
* This function checks if the current user has an active subscription. If the user is subscribed,
* their email is returned. Otherwise, it returns an empty string to indicate no active subscription.
*
* @return string The email address of the subscribed user or an empty string if not subscribed.
*/
public function get_subscriber_email() {
if ( ! is_user_logged_in() ) {
return '';
}
return wp_get_current_user()->user_email;
}
/**
* Returns true if the current authenticated user is subscribed to the current site.
*
* @return bool
*/
public function is_current_user_subscribed(): bool {
include_once WP_CONTENT_DIR . '/mu-plugins/email-subscriptions/subscriptions.php';
$email = wp_get_current_user()->user_email;
$subscriber_object = \Blog_Subscriber::get( $email );
if ( empty( $subscriber_object ) ) {
return false;
}
$blog_id = $this->get_site_id();
$subscription_status = \Blog_Subscription::get_subscription_status_for_blog( $subscriber_object, $blog_id );
if ( 'active' !== $subscription_status ) {
return false;
}
return true;
}
/**
* Returns true if the current authenticated user has a pending subscription to the current site.
*
* @return bool
*/
public function is_current_user_pending_subscriber(): bool {
include_once WP_CONTENT_DIR . '/mu-plugins/email-subscriptions/subscriptions.php';
$email = wp_get_current_user()->user_email;
$subscriber_object = \Blog_Subscriber::get( $email );
if ( empty( $subscriber_object ) ) {
return false;
}
$blog_id = $this->get_site_id();
$subscription_status = \Blog_Subscription::get_subscription_status_for_blog( $subscriber_object, $blog_id );
if ( self::BLOG_SUB_PENDING !== $subscription_status ) {
return false;
}
return true;
}
/**
* Lookup users subscriptions for a site and determine if the user has a valid subscription to match the plan ID
*
* @param array $valid_plan_ids .
* @param string $access_level .
* @param bool $is_blog_subscriber .
* @param int $post_id .
*
* @return bool
*/
protected function user_can_view_content( $valid_plan_ids, $access_level, $is_blog_subscriber, $post_id ) {
$user_id = is_user_logged_in() ? wp_get_current_user()->ID : $this->user_id;
/**
* Filter the subscriptions attached to a specific user on a given site.
*
* @since 9.4.0
*
* @param array $subscriptions Array of subscriptions.
* @param int $user_id The user's ID.
* @param int $site_id ID of the current site.
*/
$subscriptions = apply_filters( 'earn_get_user_subscriptions_for_site_id', array(), $user_id, $this->get_site_id() );
// format the subscriptions so that they can be validated.
$subscriptions = self::abbreviate_subscriptions( $subscriptions );
$is_paid_subscriber = static::validate_subscriptions( $valid_plan_ids, $subscriptions );
return $this->user_has_access( $access_level, $is_blog_subscriber, $is_paid_subscriber, $post_id, $subscriptions );
}
/**
* Report the subscriptions as an ID => [ 'end_date' => ]. mapping
*
* @param array $subscriptions_from_bd .
*
* @return array<int, object>
*/
public static function abbreviate_subscriptions( $subscriptions_from_bd ) {
$subscriptions = array();
foreach ( $subscriptions_from_bd as $subscription ) {
// We are picking the expiry date that is the most in the future.
if (
'active' === $subscription['status'] && (
! isset( $subscriptions[ $subscription['product_id'] ] ) ||
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.
strtotime( $subscription['end_date'] ) > strtotime( (string) $subscriptions[ $subscription['product_id'] ]->end_date )
)
) {
$subscriptions[ $subscription['product_id'] ] = new \stdClass();
$subscriptions[ $subscription['product_id'] ]->end_date = empty( $subscription['end_date'] ) ? gmdate( 'Y-m-d H:i:s', ( time() + 365 * 24 * 3600 ) ) : $subscription['end_date'];
}
}
return $subscriptions;
}
/**
* Get the site ID.
*
* @return int The site ID.
*/
public function get_site_id() {
return get_current_blog_id();
}
}
@@ -0,0 +1,85 @@
<?php
/**
* Subcription service includes to build out the service.
*
* @package Automattic\Jetpack\Extensions\Premium_Content
*/
namespace Automattic\Jetpack\Extensions\Premium_Content;
require_once __DIR__ . '/class-jwt.php';
require_once __DIR__ . '/interface-subscription-service.php';
require_once __DIR__ . '/class-abstract-token-subscription-service.php';
require_once __DIR__ . '/class-jetpack-token-subscription-service.php';
require_once __DIR__ . '/class-wpcom-online-subscription-service.php';
require_once __DIR__ . '/class-wpcom-offline-subscription-service.php';
require_once __DIR__ . '/class-unconfigured-subscription-service.php';
use Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\Jetpack_Token_Subscription_Service;
use Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\Subscription_Service;
use Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\Unconfigured_Subscription_Service;
use Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\WPCOM_Online_Subscription_Service;
const PAYWALL_FILTER = 'earn_premium_content_subscription_service';
/**
* Initializes the premium content subscription service.
*/
function paywall_initialize() {
$paywall = subscription_service();
if ( $paywall ) {
$paywall->initialize();
}
}
add_action( 'init', 'Automattic\Jetpack\Extensions\Premium_Content\paywall_initialize', 9 );
/**
* Gets the service handling the premium content subscriptions.
*
* @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.
* @return Subscription_Service Service that will handle the premium content subscriptions.
*/
function subscription_service( $user_id = null ) {
/**
* Filter the Jetpack_Token_Subscription_Service class.
*
* @since 9.4.0
*
* @param null|Jetpack_Token_Subscription_Service $interface Registered Subscription_Service.
*/
$interface = apply_filters( PAYWALL_FILTER, null, $user_id );
if ( ! $interface instanceof Jetpack_Token_Subscription_Service ) {
_doing_it_wrong( __FUNCTION__, 'No Subscription_Service registered for the ' . esc_html( PAYWALL_FILTER ) . ' filter', 'jetpack' );
}
return $interface;
}
/**
* Gets the default service handling the premium content.
*
* @param Subscription_Service $service If set, this service will be used by default.
* @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.
* @return Subscription_Service Service that will handle the premium content.
*/
function default_service( $service, $user_id = null ) {
if ( null !== $service ) {
return $service;
}
// Prefer to use the WPCOM_Online_Subscription_Service if this code is executing on WPCOM.
$wpcom_available_but_user_is_not_logged_in = defined( 'IS_WPCOM' ) && IS_WPCOM && ! is_user_logged_in() && ! empty( $user_id );
if ( WPCOM_Online_Subscription_Service::available() || $wpcom_available_but_user_is_not_logged_in ) {
// Return the WPCOM Online subscription service when we are on WPCOM.
return new WPCOM_Online_Subscription_Service( $user_id );
}
// Fallback on using the Jetpack_Token_Subscription_Service if this is not executing on WPCOM but is executing on a Jetpack site.
if ( Jetpack_Token_Subscription_Service::available() ) {
// Return the Jetpack Token Subscription Service when it is available.
return new Jetpack_Token_Subscription_Service();
}
// Return an Unconfigured Subscription Service if this is not a WPCOM or Jetpack site or if both of those services are not available.
return new Unconfigured_Subscription_Service();
}
add_filter( PAYWALL_FILTER, 'Automattic\Jetpack\Extensions\Premium_Content\default_service', 10, 2 );
@@ -0,0 +1,65 @@
<?php
/**
* The Subscription Service represents the entity responsible for making sure a visitor
* can see blocks that are considered premium content.
*
* If a visitor is not allowed to see they need to be given a way gain access.
*
* It is assumed that it will be a monetary exchange but that is up to the host
* that brokers the content exchange.
*
* @package Automattic\Jetpack\Extensions\Premium_Content;
*/
namespace Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service;
interface Subscription_Service {
/**
* The subscription service can be used.
*
* @return bool
*/
public static function available();
/**
* Allows a Subscription Service to setup anything it needs to provide its features.
*
* This is called during an `init` action hook callback.
*
* Examples of things a Service may want to do here:
* - Determine a visitor is arriving with a new token to unlock content and
* store the token for future browsing (e.g. in a cookie)
* - Set up WP-API endpoints necessary for the function to work
* - Token refreshes
*
* @return void
*/
public function initialize();
/**
* Given a token (this could be from a cookie, a querystring, or some other means)
* can the visitor see the premium content?
*
* @param array $valid_plan_ids .
* @param string $access_level .
*
* @return bool
*/
public function visitor_can_view_content( $valid_plan_ids, $access_level );
/**
* Is the current user a pending subscriber for the current site?
*
* @return bool
*/
public function is_current_user_pending_subscriber(): bool;
/**
* The current visitor would like to obtain access. Where do they go?
*
* @param string $mode .
* @return string
*/
public function access_url( $mode = 'subscribe' );
}
@@ -0,0 +1,42 @@
<?php
/**
* Premium Content Buttons Child Block.
*
* @package automattic/jetpack
*/
namespace Automattic\Jetpack\Extensions\Premium_Content;
use Automattic\Jetpack\Blocks;
use Jetpack_Gutenberg;
const BUTTONS_NAME = 'premium-content/buttons';
/**
* Registers the block for use in Gutenberg
* This is done via an action so that we can disable
* registration if we need to.
*/
function register_buttons_block() {
Blocks::jetpack_register_block(
BUTTONS_NAME,
array(
'render_callback' => __NAMESPACE__ . '\render_buttons_block',
)
);
}
add_action( 'init', __NAMESPACE__ . '\register_buttons_block' );
/**
* Render callback.
*
* @param array $attributes Array containing the block attributes.
* @param string $content String containing the block content.
*
* @return string
*/
function render_buttons_block( $attributes, $content ) {
Jetpack_Gutenberg::load_styles_as_required( BUTTONS_NAME );
return $content;
}
@@ -0,0 +1,65 @@
<?php
/**
* Premium Content Logged Out View Child Block.
*
* @package automattic/jetpack
*/
namespace Automattic\Jetpack\Extensions\Premium_Content;
use Automattic\Jetpack\Blocks;
use Jetpack_Gutenberg;
const LOGGEDOUT_VIEW_NAME = 'premium-content/logged-out-view';
require_once dirname( __DIR__ ) . '/_inc/access-check.php';
/**
* Registers the block for use in Gutenberg
* This is done via an action so that we can disable
* registration if we need to.
*/
function register_loggedout_view_block() {
Blocks::jetpack_register_block(
LOGGEDOUT_VIEW_NAME,
array(
'render_callback' => __NAMESPACE__ . '\render_loggedout_view_block',
'uses_context' => array( 'premium-content/planId', 'premium-content/planIds' ),
)
);
}
add_action( 'init', __NAMESPACE__ . '\register_loggedout_view_block' );
/**
* Render callback.
*
* @param array $attributes Array containing the block attributes.
* @param string $content String containing the block content.
* @param object $block Object containing block details.
*
* @return string
*/
function render_loggedout_view_block( $attributes, $content, $block = null ) {
if ( ! pre_render_checks() ) {
return '';
}
$visitor_has_access = current_visitor_can_access( $attributes, $block );
if ( $visitor_has_access ) {
// The viewer has access to premium content, so the viewer shouldn't see the logged out view.
return '';
}
Jetpack_Gutenberg::load_styles_as_required( LOGGEDOUT_VIEW_NAME );
// Old versions of the block were rendering the subscribe/login button server-side, so we need to still support them.
if ( ! empty( $attributes['buttonClasses'] ) ) {
require_once __DIR__ . '/../_inc/legacy-buttons.php';
$buttons = create_legacy_buttons_markup( $attributes, $content, $block );
return $content . $buttons;
}
return $content;
}
@@ -0,0 +1,109 @@
<?php
/**
* Premium Content Login Button Child Block.
*
* @package automattic/jetpack
*/
namespace Automattic\Jetpack\Extensions\Premium_Content;
use Automattic\Jetpack\Blocks;
use Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\Abstract_Token_Subscription_Service;
use Automattic\Jetpack\Status\Host;
use Jetpack_Gutenberg;
use Jetpack_Options;
require_once dirname( __DIR__ ) . '/_inc/subscription-service/include.php';
const LOGIN_BUTTON_NAME = 'premium-content/login-button';
/**
* Registers the block for use in Gutenberg
* This is done via an action so that we can disable
* registration if we need to.
*/
function register_login_button_block() {
Blocks::jetpack_register_block(
LOGIN_BUTTON_NAME,
array(
'render_callback' => __NAMESPACE__ . '\render_login_button_block',
)
);
}
add_action( 'init', __NAMESPACE__ . '\register_login_button_block' );
/**
* Returns current URL.
*
* @return string
*/
function get_current_url() {
if ( ! isset( $_SERVER['HTTP_HOST'] ) || ! isset( $_SERVER['REQUEST_URI'] ) ) {
return '';
}
return ( is_ssl() ? 'https://' : 'http://' ) . wp_unslash( $_SERVER['HTTP_HOST'] ) . wp_unslash( $_SERVER['REQUEST_URI'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
}
/**
* Returns subscriber log in URL.
*
* @param string $redirect Path to redirect to on login.
*
* @return string
*/
function get_subscriber_login_url( $redirect ) {
$redirect = ! empty( $redirect ) ? $redirect : get_site_url();
if ( ( new Host() )->is_wpcom_simple() ) {
// On WPCOM we will redirect immediately
return wpcom_logmein_redirect_url( $redirect, false, null, 'link', get_current_blog_id() );
}
// On self-hosted we will save and hide the token
$redirect_url = get_site_url() . '/wp-json/jetpack/v4/subscribers/auth';
$redirect_url = add_query_arg( 'redirect_url', $redirect, $redirect_url );
return add_query_arg(
array(
'site_id' => intval( Jetpack_Options::get_option( 'id' ) ),
'redirect_url' => rawurlencode( $redirect_url ),
),
'https://subscribe.wordpress.com/memberships/jwt/'
);
}
/**
* Determines whether the current visitor is a logged in user or a subscriber.
*
* @return bool
*/
function is_subscriber_logged_in() {
return is_user_logged_in() || Abstract_Token_Subscription_Service::has_token_from_cookie();
}
/**
* Render callback.
*
* @param array $attributes Array containing the block attributes.
* @param string $content String containing the block content.
*
* @return string
*/
function render_login_button_block( $attributes, $content ) {
if ( ! pre_render_checks() ) {
return '';
}
// The viewer is logged it, so they shouldn't see the login button.
if ( is_subscriber_logged_in() ) {
return '';
}
Jetpack_Gutenberg::load_styles_as_required( LOGIN_BUTTON_NAME );
$redirect_url = get_current_url();
$url = get_subscriber_login_url( $redirect_url );
return preg_replace( '/(<a\b[^><]*)>/i', '$1 href="' . esc_url( $url ) . '">', $content );
}
@@ -0,0 +1,172 @@
<?php
/**
* Premium Content Block.
*
* @package automattic/jetpack
*/
namespace Automattic\Jetpack\Extensions\Premium_Content;
use Automattic\Jetpack\Blocks;
use Jetpack_Gutenberg;
use WP_Post;
use const Automattic\Jetpack\Extensions\Subscriptions\META_NAME_CONTAINS_PAID_CONTENT;
require_once __DIR__ . '/_inc/access-check.php';
require_once __DIR__ . '/logged-out-view/logged-out-view.php';
require_once __DIR__ . '/subscriber-view/subscriber-view.php';
require_once __DIR__ . '/buttons/buttons.php';
require_once __DIR__ . '/login-button/login-button.php';
require_once JETPACK__PLUGIN_DIR . 'extensions/blocks/subscriptions/constants.php';
/**
* Registers the block for use in Gutenberg
* This is done via an action so that we can disable
* registration if we need to.
*/
function register_block() {
require_once JETPACK__PLUGIN_DIR . '/modules/memberships/class-jetpack-memberships.php';
if ( \Jetpack_Memberships::should_enable_monetize_blocks_in_editor() ) {
Blocks::jetpack_register_block(
__DIR__,
array(
'render_callback' => __NAMESPACE__ . '\render_block',
'provides_context' => array(
'premium-content/planId' => 'selectedPlanId', // Deprecated.
'premium-content/planIds' => 'selectedPlanIds',
'isPremiumContentChild' => 'isPremiumContentChild',
),
)
);
}
register_post_meta(
'post',
META_NAME_CONTAINS_PAID_CONTENT,
array(
'show_in_rest' => true,
'single' => true,
'type' => 'boolean',
'auth_callback' => function () {
return wp_get_current_user()->has_cap( 'edit_posts' );
},
)
);
// This ensures Jetpack will sync this post meta to WPCOM.
add_filter(
'jetpack_sync_post_meta_whitelist',
function ( $allowed_meta ) {
return array_merge(
$allowed_meta,
array(
META_NAME_CONTAINS_PAID_CONTENT,
)
);
}
);
add_action( 'wp_after_insert_post', __NAMESPACE__ . '\add_paid_content_post_meta', 99, 2 );
}
add_action( 'init', __NAMESPACE__ . '\register_block' );
/**
* Render callback.
*
* @param array $attributes Array containing the block attributes.
* @param string $content String containing the block content.
*
* @return string
*/
function render_block( $attributes, $content ) {
if ( ! pre_render_checks() ) {
return '';
}
// Render the Stripe nudge when Stripe is unconnected
if ( ! membership_checks() ) {
$stripe_nudge = render_stripe_nudge();
return $stripe_nudge . $content;
}
// We don't use FEATURE_NAME here because styles are not in /container folder.
Jetpack_Gutenberg::load_assets_as_required( 'premium-content' );
return $content;
}
/**
* Server-side rendering for the stripe connection nudge.
*
* @return string Final content to render.
*/
function render_stripe_nudge() {
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
\require_lib( 'memberships' );
$blog_id = get_current_blog_id();
$settings = (array) \get_memberships_settings_for_site( $blog_id );
return stripe_nudge(
$settings['connect_url'],
__( 'Connect to Stripe to use this block on your site.', 'jetpack' ),
__( 'Connect', 'jetpack' )
);
} else {
// On WoA sites, the Stripe connection url is not easily available
// server-side, so we redirect them to the post in the editor in order
// to connect.
return stripe_nudge(
get_edit_post_link( get_the_ID() ),
__( 'Connect to Stripe in the editor to use this block on your site.', 'jetpack' ),
__( 'Edit post', 'jetpack' )
);
}
}
/**
* Render the stripe nudge.
*
* @param string $checkout_url Url for the CTA.
* @param string $description Text of the nudge.
* @param string $button_text Text of the button.
*
* @return string Final content to render.
*/
function stripe_nudge( $checkout_url, $description, $button_text ) {
require_once JETPACK__PLUGIN_DIR . '_inc/lib/components.php';
return \Jetpack_Components::render_frontend_nudge(
array(
'checkoutUrl' => $checkout_url,
'description' => $description,
'buttonText' => $button_text,
)
);
}
/**
* Add a meta to prevent publication on firehose, ES AI or Reader
*
* @param int $post_id Post id.
* @param WP_Post $post Post being saved.
* @return void
*/
function add_paid_content_post_meta( int $post_id, WP_Post $post ) {
if ( $post->post_type !== 'post' && $post->post_type !== 'page' ) {
return;
}
$contains_paid_content = has_block( 'premium-content/container', $post );
if ( $contains_paid_content ) {
update_post_meta(
$post_id,
META_NAME_CONTAINS_PAID_CONTENT,
$contains_paid_content
);
}
if ( ! $contains_paid_content ) {
delete_post_meta(
$post_id,
META_NAME_CONTAINS_PAID_CONTENT
);
}
}
@@ -0,0 +1,57 @@
<?php
/**
* Premium Content Subscriber View Child Block.
*
* @package automattic/jetpack
*/
namespace Automattic\Jetpack\Extensions\Premium_Content;
use Automattic\Jetpack\Blocks;
use Jetpack_Gutenberg;
const SUBSCRIBER_VIEW_NAME = 'premium-content/subscriber-view';
require_once dirname( __DIR__ ) . '/_inc/access-check.php';
/**
* Registers the block for use in Gutenberg
* This is done via an action so that we can disable
* registration if we need to.
*/
function register_subscriber_view_block() {
Blocks::jetpack_register_block(
SUBSCRIBER_VIEW_NAME,
array(
'render_callback' => __NAMESPACE__ . '\render_subscriber_view_block',
'uses_context' => array( 'premium-content/planId', 'premium-content/planIds' ),
)
);
}
add_action( 'init', __NAMESPACE__ . '\register_subscriber_view_block' );
/**
* Render callback.
*
* @param array $attributes Array containing the block attributes.
* @param string $content String containing the block content.
* @param object $block Object containing the full block.
*
* @return string
*/
function render_subscriber_view_block( $attributes, $content, $block = null ) {
if ( ! pre_render_checks() ) {
return '';
}
$visitor_has_access = current_visitor_can_access( $attributes, $block );
if ( $visitor_has_access ) {
Jetpack_Gutenberg::load_styles_as_required( SUBSCRIBER_VIEW_NAME );
// The viewer has access to premium content, so the viewer can see the subscriber view content.
return $content;
}
return '';
}