diff options
Diffstat (limited to 'plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service')
9 files changed, 1098 insertions, 0 deletions
diff --git a/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-jetpack-token-subscription-service.php b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-jetpack-token-subscription-service.php new file mode 100644 index 00000000..2dc8cfb3 --- /dev/null +++ b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-jetpack-token-subscription-service.php @@ -0,0 +1,50 @@ +<?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; + +/** + * Class Jetpack_Token_Subscription_Service + * + * @package Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service + */ +class Jetpack_Token_Subscription_Service extends Token_Subscription_Service { + + /** + * Is the Jetpack_Options class available? + * + * @return bool Whether Jetpack_Options class exists. + */ + public static function available() { + return 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() { + $token = ( new Tokens() )->get_access_token(); + if ( ! isset( $token->secret ) ) { + return false; + } + return $token->secret; + } +} diff --git a/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-jwt.php b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-jwt.php new file mode 100644 index 00000000..f2d1f1d8 --- /dev/null +++ b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-jwt.php @@ -0,0 +1,441 @@ +<?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 = is_null( static::$timestamp ) ? 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_decode( static::json_encode( $header ) ); + $segments[] = static::urlsafe_b64_decode( static::json_encode( $payload ) ); + $signing_input = implode( '.', $segments ); + + $signature = static::sign( $signing_input, $key, $alg ); + $segments[] = static::urlsafe_b64_decode( $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 ) { + if ( version_compare( PHP_VERSION, '5.4.0', '>=' ) && ! ( defined( 'JSON_C_VERSION' ) && PHP_INT_SIZE > 4 ) ) { + /** In PHP >=5.4.0, json_decode() accepts an options parameter, that allows you + * to specify that large ints (like Steam Transaction IDs) should be treated as + * strings, rather than the PHP default behaviour of converting them to floats. + */ + $obj = json_decode( $input, false, 512, JSON_BIGINT_AS_STRING ); + } else { + /** Not all servers will support that, however, so for older versions we must + * manually detect large ints in the JSON string and quote them (thus converting + *them to strings) before decoding, hence the preg_replace() call. + */ + $max_int_length = strlen( (string) PHP_INT_MAX ) - 1; + $json_without_bigints = preg_replace( '/:\s*(-?\d{' . $max_int_length . ',})/', ': "$1"', $input ); + $obj = json_decode( $json_without_bigints ); + } + + $errno = json_last_error(); + + if ( $errno && function_exists( 'json_last_error' ) ) { + 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 && function_exists( 'json_last_error' ) ) { + 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 void + */ + 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 diff --git a/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-subscription-service.php b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-subscription-service.php new file mode 100644 index 00000000..b433182a --- /dev/null +++ b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-subscription-service.php @@ -0,0 +1,57 @@ +<?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 boolean + */ + 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 . + * + * @return boolean + */ + public function visitor_can_view_content( $valid_plan_ids ); + + /** + * The current visitor would like to obtain access. Where do they go? + * + * @param string $mode . + * @return string + */ + public function access_url( $mode = 'subscribe' ); +} diff --git a/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-token-subscription-service.php b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-token-subscription-service.php new file mode 100644 index 00000000..05791022 --- /dev/null +++ b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-token-subscription-service.php @@ -0,0 +1,264 @@ +<?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; + +/** + * Class Token_Subscription_Service + * + * @package Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service + */ +abstract class Token_Subscription_Service implements Subscription_Service { + + const JWT_AUTH_TOKEN_COOKIE_NAME = 'jp-premium-content-session'; + const DECODE_EXCEPTION_FEATURE = 'memberships'; + const DECODE_EXCEPTION_MESSAGE = 'Problem decoding provided token'; + const REST_URL_ORIGIN = 'https://subscribe.wordpress.com/'; + + /** + * Initialize the token subscription service. + * + * @inheritDoc + */ + public function initialize() { + $token = $this->token_from_request(); + if ( null !== $token ) { + $this->set_token_cookie( $token ); + } + } + + /** + * 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. + */ + public function visitor_can_view_content( $valid_plan_ids ) { + + // URL token always has a precedence, so it can overwrite the cookie when new data available. + $token = $this->token_from_request(); + if ( $token ) { + $this->set_token_cookie( $token ); + } else { + $token = $this->token_from_cookie(); + } + + $is_valid_token = true; + + if ( empty( $token ) ) { + // no token, no access. + $is_valid_token = false; + } else { + $payload = $this->decode_token( $token ); + if ( empty( $payload ) ) { + $is_valid_token = false; + } + } + + if ( $is_valid_token ) { + $subscriptions = (array) $payload['subscriptions']; + } elseif ( is_user_logged_in() ) { + /* + * If there is no token, but the user is logged in, + * get current subscriptions and determine if the user has + * a valid subscription to match the plan 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(), + wp_get_current_user()->ID, + $this->get_site_id() + ); + + if ( empty( $subscriptions ) ) { + return false; + } + // format the subscriptions so that they can be validated. + $subscriptions = self::abbreviate_subscriptions( $subscriptions ); + } else { + return false; + } + + return $this->validate_subscriptions( $valid_plan_ids, $subscriptions ); + } + + /** + * Decode the given token. + * + * @param string $token Token to decode. + * + * @return array|false + */ + public function decode_token( $token ) { + 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(); + + /** + * Get the ID of the current site. + * + * @return int + */ + abstract public function get_site_id(); + + // 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' ) { + global $wp; + $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 ]; + } + } + + /** + * Store the auth cookie. + * + * @param string $token Auth token. + * @return void + */ + private function set_token_cookie( $token ) { + if ( ! empty( $token ) ) { + setcookie( self::JWT_AUTH_TOKEN_COOKIE_NAME, $token, 0, '/' ); + } + } + + /** + * 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'] ) ) { + // 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 array<int, Token_Subscription> $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 + */ + protected function validate_subscriptions( $valid_plan_ids, $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( $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 ) { + 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 ) { + $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; + } +} diff --git a/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-token-subscription.php b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-token-subscription.php new file mode 100644 index 00000000..d9c81e8c --- /dev/null +++ b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-token-subscription.php @@ -0,0 +1,23 @@ +<?php +/** + * Token subscription management. + * + * @package Automattic\Jetpack\Extensions\Premium_Content + */ + +namespace Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service; + +/** + * Class Token_Subscription + * + * @package Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service + */ +class Token_Subscription { + + /** + * End date. + * + * @var string $end_date . + */ + public $end_date = ''; +} diff --git a/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-unconfigured-subscription-service.php b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-unconfigured-subscription-service.php new file mode 100644 index 00000000..a89662a2 --- /dev/null +++ b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-unconfigured-subscription-service.php @@ -0,0 +1,58 @@ +<?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 . + */ + public function visitor_can_view_content( $valid_plan_ids ) { + 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 diff --git a/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-wpcom-offline-subscription-service.php b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-wpcom-offline-subscription-service.php new file mode 100644 index 00000000..12283a96 --- /dev/null +++ b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-wpcom-offline-subscription-service.php @@ -0,0 +1,77 @@ +<?php +/** + * This subscription service is used when a subscriber is offline and a token is not available. + * This subscription service will be used when rendering content in email and reader on WPCOM only. + * When content is being rendered, the current user and site are set. + * This allows us to lookup a users subscriptions and determine if the + * offline visitor can view content that has been deemed "Premium content". + * + * @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_Offline_Subscription_Service extends WPCOM_Token_Subscription_Service { + + /** + * Is available() + * + * @return bool + */ + public static function available() { + // Return available if the user is logged in and either + // running a job (sending email subscription) OR + // handling API request on WPCOM (reader). + return ( + ( defined( 'WPCOM_JOBS' ) && WPCOM_JOBS ) || + ( defined( 'IS_WPCOM' ) && IS_WPCOM === true && ( defined( 'REST_API_REQUEST' ) && REST_API_REQUEST ) ) + ) && 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 . + * @return bool + */ + public function visitor_can_view_content( $valid_plan_ids ) { + /** This filter is already documented in projects/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-token-subscription-service.php */ + $subscriptions = apply_filters( 'earn_get_user_subscriptions_for_site_id', array(), wp_get_current_user()->ID, $this->get_site_id() ); + if ( empty( $subscriptions ) ) { + return false; + } + // format the subscriptions so that they can be validated. + $subscriptions = self::abbreviate_subscriptions( $subscriptions ); + return $this->validate_subscriptions( $valid_plan_ids, $subscriptions ); + } + + /** + * Report the subscriptions as an ID => [ 'end_date' => ]. mapping + * + * @param array $subscriptions_from_bd . + * + * @return array<int, array> + */ + 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'] ) ? ( time() + 365 * 24 * 3600 ) : $subscription['end_date']; + } + } + return $subscriptions; + } +} diff --git a/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-wpcom-token-subscription-service.php b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-wpcom-token-subscription-service.php new file mode 100644 index 00000000..1bb80b6b --- /dev/null +++ b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/class-wpcom-token-subscription-service.php @@ -0,0 +1,46 @@ +<?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; + +/** + * Class WPCOM_Token_Subscription_Service + * + * @package Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service + */ +class WPCOM_Token_Subscription_Service extends Token_Subscription_Service { + + /** + * Is available() + * + * @inheritDoc + */ + public static function available() { + // phpcs:ignore ImportDetection.Imports.RequireImports.Symbol + return defined( 'IS_WPCOM' ) && IS_WPCOM === true; + } + + /** + * Is get_site_id() + * + * @inheritDoc + */ + public function get_site_id() { + return get_current_blog_id(); + } + + /** + * Is get_key() + * + * @inheritDoc + */ + public function get_key() { + // phpcs:ignore ImportDetection.Imports.RequireImports.Symbol + return defined( 'EARN_JWT_SIGNING_KEY' ) ? EARN_JWT_SIGNING_KEY : false; + } +} diff --git a/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/include.php b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/include.php new file mode 100644 index 00000000..34bf3f20 --- /dev/null +++ b/plugins/jetpack/extensions/blocks/premium-content/_inc/subscription-service/include.php @@ -0,0 +1,82 @@ +<?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__ . '/class-subscription-service.php'; +require_once __DIR__ . '/class-token-subscription.php'; +require_once __DIR__ . '/class-token-subscription-service.php'; +require_once __DIR__ . '/class-wpcom-token-subscription-service.php'; +require_once __DIR__ . '/class-wpcom-offline-subscription-service.php'; +require_once __DIR__ . '/class-jetpack-token-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\Unconfigured_Subscription_Service; +use Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\WPCOM_Offline_Subscription_Service; +use Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\WPCOM_Token_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. + * + * @return Subscription_Service Service that will handle the premium content subscriptions. + */ +function subscription_service() { + /** + * 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 ); + 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. + * @return Subscription_Service Service that will handle the premium content. + */ +function default_service( $service ) { + if ( null !== $service ) { + return $service; + } + + if ( WPCOM_Offline_Subscription_Service::available() ) { + return new WPCOM_Offline_Subscription_Service(); + } + + if ( WPCOM_Token_Subscription_Service::available() ) { + return new WPCOM_Token_Subscription_Service(); + } + + if ( Jetpack_Token_Subscription_Service::available() ) { + return new Jetpack_Token_Subscription_Service(); + } + + return new Unconfigured_Subscription_Service(); +} +add_filter( PAYWALL_FILTER, 'Automattic\Jetpack\Extensions\Premium_Content\default_service' ); |