From 477cf569add13e08235ff38a966a15b3f39b3dc1 Mon Sep 17 00:00:00 2001 From: Miguel Torres Date: Mon, 21 Sep 2020 22:08:44 +0200 Subject: [PATCH] Premium Content: Display subscriber view to logged in users who have subscribed (#45777) --- .../premium-content/blocks/buttons/edit.js | 42 ++++++- .../premium-content/blocks/buttons/index.js | 2 +- .../premium-content/blocks/container/edit.js | 14 +-- .../blocks/logged-out-view/edit.js | 104 +++++---------- .../blocks/subscriber-view/edit.js | 77 +++++------- .../class-token-subscription-service.php | 119 ++++++++++++++---- 6 files changed, 196 insertions(+), 162 deletions(-) diff --git a/apps/editing-toolkit/editing-toolkit-plugin/premium-content/blocks/buttons/edit.js b/apps/editing-toolkit/editing-toolkit-plugin/premium-content/blocks/buttons/edit.js index 6845b129c72a2d..e498d5361376d9 100644 --- a/apps/editing-toolkit/editing-toolkit-plugin/premium-content/blocks/buttons/edit.js +++ b/apps/editing-toolkit/editing-toolkit-plugin/premium-content/blocks/buttons/edit.js @@ -24,7 +24,13 @@ const alignmentHooksSetting = { isEmbedButton: true, }; -function ButtonsEdit( { context, subscribeButton, setSubscribeButtonPlan } ) { +function ButtonsEdit( { + context, + jetpackButton, + subscribeButton, + setSubscribeButtonText, + setSubscribeButtonPlan, +} ) { const planId = context ? context[ 'premium-content/planId' ] : null; const template = [ @@ -68,6 +74,14 @@ function ButtonsEdit( { context, subscribeButton, setSubscribeButtonPlan } ) { ); }, [ subscribeButton ] ); + // Updates the subscribe button text. + useEffect( () => { + if ( ! jetpackButton ) { + return; + } + setSubscribeButtonText( __( 'Subscribe', 'full-site-editing' ) ); + }, [ jetpackButton, setSubscribeButtonText ] ); + return ( // eslint-disable-next-line wpcalypso/jsx-classname-namespace @@ -83,13 +97,21 @@ function ButtonsEdit( { context, subscribeButton, setSubscribeButtonPlan } ) { } export default compose( [ - withSelect( ( select, props ) => ( { + withSelect( ( select, props ) => { // Only first block is assumed to be a subscribe button (users can add additional Recurring Payments blocks for // other plans). - subscribeButton: select( 'core/block-editor' ) + const subscribeButton = select( 'core/block-editor' ) .getBlock( props.clientId ) - .innerBlocks.find( ( block ) => block.name === 'jetpack/recurring-payments' ), - } ) ), + .innerBlocks.find( ( block ) => block.name === 'jetpack/recurring-payments' ); + + const jetpackButton = select( 'core/block-editor' ) + .getBlock( subscribeButton.clientId ) + .innerBlocks.find( ( block ) => block.name === 'jetpack/button' ); + return { + subscribeButton, + jetpackButton, + }; + } ), withDispatch( ( dispatch, props ) => ( { /** * Updates the plan on the Recurring Payments block acting as a subscribe button. @@ -101,5 +123,15 @@ export default compose( [ planId, } ); }, + /** + * Updates the button text on the Recurring Payments block acting as a subscribe button. + * + * @param text {string} Button text. + */ + setSubscribeButtonText( text ) { + dispatch( 'core/block-editor' ).updateBlockAttributes( props.jetpackButton.clientId, { + text, + } ); + }, } ) ), ] )( ButtonsEdit ); diff --git a/apps/editing-toolkit/editing-toolkit-plugin/premium-content/blocks/buttons/index.js b/apps/editing-toolkit/editing-toolkit-plugin/premium-content/blocks/buttons/index.js index c0d1c65f233de2..7889cd1154cfa8 100644 --- a/apps/editing-toolkit/editing-toolkit-plugin/premium-content/blocks/buttons/index.js +++ b/apps/editing-toolkit/editing-toolkit-plugin/premium-content/blocks/buttons/index.js @@ -31,7 +31,7 @@ const settings = { keywords: [ __( 'link', 'full-site-editing' ) ], edit, save, - context: [ 'premium-content/planId' ], + usesContext: [ 'premium-content/planId' ], }; export { name, category, settings }; diff --git a/apps/editing-toolkit/editing-toolkit-plugin/premium-content/blocks/container/edit.js b/apps/editing-toolkit/editing-toolkit-plugin/premium-content/blocks/container/edit.js index e06b52f1677861..cc722f6983fa6b 100644 --- a/apps/editing-toolkit/editing-toolkit-plugin/premium-content/blocks/container/edit.js +++ b/apps/editing-toolkit/editing-toolkit-plugin/premium-content/blocks/container/edit.js @@ -70,7 +70,6 @@ const defaultString = null; * @property { boolean } isSelected * @property { string } className * @property { string } clientId - * @property { string } containerClientId * @property { Attributes } attributes * @property { (attributes: object) => void } setAttributes * @property { ?object } noticeUI @@ -275,7 +274,10 @@ function Edit( props ) { onError( props, result.message ); } ); - props.selectBlock(); + + // Execution delayed with setTimeout to ensure it runs after any block auto-selection performed by inner blocks + // (such as the Recurring Payments block) + setTimeout( () => props.selectBlock(), 1000 ); }, [] ); if ( apiState === API_STATE_LOADING ) { @@ -458,14 +460,10 @@ function getConnectUrl( props, connectURL ) { } export default compose( [ - withSelect( ( select, ownProps ) => { + withSelect( ( select ) => { const { getCurrentPostId } = select( 'core/editor' ); return { postId: getCurrentPostId(), - // @ts-ignore difficult to type via JSDoc - containerClientId: select( 'core/block-editor' ).getBlockHierarchyRootClientId( - ownProps.clientId - ), }; } ), withNotices, @@ -474,7 +472,7 @@ export default compose( [ return { selectBlock() { // @ts-ignore difficult to type via JSDoc - blockEditor.selectBlock( ownProps.containerClientId ); + blockEditor.selectBlock( ownProps.clientId ); }, }; } ), diff --git a/apps/editing-toolkit/editing-toolkit-plugin/premium-content/blocks/logged-out-view/edit.js b/apps/editing-toolkit/editing-toolkit-plugin/premium-content/blocks/logged-out-view/edit.js index 0d852a5b7e0e42..c200fd131fdee1 100644 --- a/apps/editing-toolkit/editing-toolkit-plugin/premium-content/blocks/logged-out-view/edit.js +++ b/apps/editing-toolkit/editing-toolkit-plugin/premium-content/blocks/logged-out-view/edit.js @@ -3,9 +3,6 @@ */ import { InnerBlocks } from '@wordpress/block-editor'; import { __ } from '@wordpress/i18n'; -import { useEffect } from '@wordpress/element'; -import { compose } from '@wordpress/compose'; -import { withDispatch, withSelect } from '@wordpress/data'; /** * Internal dependencies @@ -14,75 +11,36 @@ import Context from '../container/context'; /** * Block edit function - * - * @typedef { import('./').Attributes } Attributes - * @typedef { object } Props - * @property { boolean } isSelected - * @property { string } className - * @property { string } clientId - * @property { string } containerClientId - * @property { Attributes } attributes - * @property { Function } setAttributes - * @property { Function } selectContainerBlock - * - * @param { Props } props Properties */ -function Edit( { selectContainerBlock } ) { - useEffect( () => { - // Selects the container block on mount. - // - // Execution delayed with setTimeout to ensure it runs after any block auto-selection performed by inner blocks - // (such as the Recurring Payments block). @see https://github.com/Automattic/wp-calypso/issues/43450 - setTimeout( selectContainerBlock, 0 ); - }, [] ); +const Edit = () => ( + + { ( { selectedTab, stripeNudge } ) => ( + /** @see https://github.com/evcohen/eslint-plugin-jsx-a11y/blob/HEAD/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events */ + // eslint-disable-next-line + + ) } + +); - return ( - - { ( { selectedTab, stripeNudge } ) => ( - /** @see https://github.com/evcohen/eslint-plugin-jsx-a11y/blob/HEAD/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events */ - // eslint-disable-next-line - - ) } - - ); -} - -export default compose( [ - withSelect( ( select, props ) => { - const { getBlockHierarchyRootClientId } = select( 'core/block-editor' ); - return { - // @ts-ignore difficult to type with JSDoc - containerClientId: getBlockHierarchyRootClientId( props.clientId ), - }; - } ), - withDispatch( ( dispatch, props ) => { - const { selectBlock } = dispatch( 'core/block-editor' ); - return { - selectContainerBlock() { - // @ts-ignore difficult to type with JSDoc - selectBlock( props.containerClientId ); - }, - }; - } ), -] )( Edit ); +export default Edit; diff --git a/apps/editing-toolkit/editing-toolkit-plugin/premium-content/blocks/subscriber-view/edit.js b/apps/editing-toolkit/editing-toolkit-plugin/premium-content/blocks/subscriber-view/edit.js index e4a403c906982e..8c529aff58c00f 100644 --- a/apps/editing-toolkit/editing-toolkit-plugin/premium-content/blocks/subscriber-view/edit.js +++ b/apps/editing-toolkit/editing-toolkit-plugin/premium-content/blocks/subscriber-view/edit.js @@ -3,9 +3,8 @@ */ import { InnerBlocks } from '@wordpress/block-editor'; import { __ } from '@wordpress/i18n'; -import { useEffect } from '@wordpress/element'; import { compose } from '@wordpress/compose'; -import { withDispatch, withSelect } from '@wordpress/data'; +import { withSelect } from '@wordpress/data'; /** * Internal dependencies @@ -17,43 +16,36 @@ import Context from '../container/context'; * * @typedef { object } Props * @property { string } clientId - * @property { string } containerClientId - * @property { Function } selectBlock + * @property { boolean } hasInnerBlocks * * @param { Props } props Properties */ -function Edit( props ) { - useEffect( () => { - props.selectBlock(); - }, [] ); - - return ( - - { ( { selectedTab, stripeNudge } ) => ( - /** @see https://github.com/evcohen/eslint-plugin-jsx-a11y/blob/HEAD/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events */ - // eslint-disable-next-line - - ) } - - ); -} +const Edit = ( props ) => ( + + { ( { selectedTab, stripeNudge } ) => ( + /** @see https://github.com/evcohen/eslint-plugin-jsx-a11y/blob/HEAD/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events */ + // eslint-disable-next-line + + ) } + +); export default compose( [ withSelect( ( select, props ) => { @@ -61,19 +53,6 @@ export default compose( [ // @ts-ignore difficult to type with JSDoc hasInnerBlocks: !! select( 'core/block-editor' ).getBlocksByClientId( props.clientId )[ 0 ] .innerBlocks.length, - // @ts-ignore difficult to type with JSDoc - containerClientId: select( 'core/block-editor' ).getBlockHierarchyRootClientId( - props.clientId - ), - }; - } ), - withDispatch( ( dispatch, props ) => { - const blockEditor = dispatch( 'core/block-editor' ); - return { - selectBlock() { - // @ts-ignore difficult to type with JSDoc - blockEditor.selectBlock( props.containerClientId ); - }, }; } ), ] )( Edit ); diff --git a/apps/editing-toolkit/editing-toolkit-plugin/premium-content/subscription-service/class-token-subscription-service.php b/apps/editing-toolkit/editing-toolkit-plugin/premium-content/subscription-service/class-token-subscription-service.php index 2d3535160275f3..1eba9c53dd1905 100644 --- a/apps/editing-toolkit/editing-toolkit-plugin/premium-content/subscription-service/class-token-subscription-service.php +++ b/apps/editing-toolkit/editing-toolkit-plugin/premium-content/subscription-service/class-token-subscription-service.php @@ -1,17 +1,25 @@ -token_from_request(); - if ( $token !== null ) { + if ( null !== $token ) { $this->set_token_cookie( $token ); } } @@ -38,6 +48,8 @@ public function initialize() { * 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 ) { @@ -49,22 +61,40 @@ public function visitor_can_view_content( $valid_plan_ids ) { $token = $this->token_from_cookie(); } + $is_valid_token = true; + if ( empty( $token ) ) { - // no token, no access - return false; + // no token, no access. + $is_valid_token = false; } $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. + $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; } - $subscriptions = (array) $payload['subscriptions']; return $this->validate_subscriptions( $valid_plan_ids, $subscriptions ); } /** - * @param string $token + * Decode the given token. + * + * @param string $token Token to decode. * * @return array|false */ @@ -78,7 +108,7 @@ public function decode_token( $token ) { $logstash = array( 'feature' => self::DECODE_EXCEPTION_FEATURE, 'message' => self::DECODE_EXCEPTION_MESSAGE, - 'extra' => json_encode( compact( 'exception', 'token' ) ), + 'extra' => wp_json_encode( compact( 'exception', 'token' ) ), ); // phpcs:ignore ImportDetection.Imports.RequireImports.Symbol log2logstash( $logstash ); @@ -87,19 +117,25 @@ public function decode_token( $token ) { } /** + * Get the key for decoding the auth token. + * * @return string|false */ - abstract function get_key(); + abstract public function get_key(); /** + * Get the ID of the current site. + * * @return int */ - abstract function get_site_id(); + abstract public function get_site_id(); /** - * @inheritDoc + * Get the URL to access the protected content. + * + * @param string $mode Access mode (either "subscribe" or "login"). */ - public function access_url( $mode = 'subscribe' ) { + public function access_url( $mode = 'subscribe' ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter global $wp; $permalink = get_permalink(); if ( empty( $permalink ) ) { @@ -111,16 +147,21 @@ public function access_url( $mode = 'subscribe' ) { } /** + * 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 ]; } } /** - * @param string $token + * Store the auth cookie. + * + * @param string $token Auth token. * @return void */ private function set_token_cookie( $token ) { @@ -130,13 +171,17 @@ private function set_token_cookie( $token ) { } /** + * 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 matches a valid JWT token pattern. $token = reset( $matches ); } } @@ -146,14 +191,14 @@ private function token_from_request() { /** * Return true if any ID/date pairs are valid. Otherwise false. * - * @param int[] $valid_plan_ids + * @param int[] $valid_plan_ids List of valid plan IDs. * @param array $token_subscriptions : ID must exist in the provided $valid_subscriptions parameter. * The provided end date needs to be greater than now(). * * @return bool */ protected function validate_subscriptions( $valid_plan_ids, $token_subscriptions ) { - // Create a list of product_ids to compare against: + // 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 ); @@ -162,10 +207,6 @@ protected function validate_subscriptions( $valid_plan_ids, $token_subscriptions } } - /** - * @var int $product_id - * @var Token_Subscription $token_subscription - */ 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 ); @@ -178,12 +219,38 @@ protected function validate_subscriptions( $valid_plan_ids, $token_subscriptions } /** - * @param int $site_id - * @param string $redirect_url - * @return string + * 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, urlencode( $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 + */ + 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; + } }