diff --git a/includes/class-recaptcha.php b/includes/class-recaptcha.php index 0533e1663d..ae14401dc4 100644 --- a/includes/class-recaptcha.php +++ b/includes/class-recaptcha.php @@ -344,18 +344,6 @@ public static function can_use_captcha( $version = null ) { return false; } - // Only use v2 if we are in modal checkout context. - // TODO: Remove this check once we have a way to enable v2 for non-modal checkouts. - if ( - ( 'v2' === $version || 'v2_invisible' === $settings['version'] ) && - ( - ! method_exists( 'Newspack_Blocks\Modal_Checkout', 'is_modal_checkout' ) || - ! \Newspack_Blocks\Modal_Checkout::is_modal_checkout() - ) - ) { - return false; - } - if ( empty( self::get_site_key() ) || empty( self::get_site_secret() ) ) { return false; } @@ -510,6 +498,20 @@ function refreshToken() { public static function verify_recaptcha_on_checkout() { $url = \home_url( \add_query_arg( null, null ) ); $should_verify_captcha = apply_filters( 'newspack_recaptcha_verify_captcha', self::can_use_captcha(), $url ); + $version = self::get_setting( 'version' ); + + // Only use v2 if we are in modal checkout context. + // TODO: Remove this check once we have a way to enable v2 for non-modal checkouts. + if ( + ( 'v2' === $version || 'v2_invisible' === $version ) && + ( + ! method_exists( 'Newspack_Blocks\Modal_Checkout', 'is_modal_checkout' ) || + ! \Newspack_Blocks\Modal_Checkout::is_modal_checkout() + ) + ) { + $should_verify_captcha = false; + } + if ( ! $should_verify_captcha ) { return; } diff --git a/src/other-scripts/recaptcha/index.js b/src/other-scripts/recaptcha/index.js index 0451a81cb1..924fc0135c 100644 --- a/src/other-scripts/recaptcha/index.js +++ b/src/other-scripts/recaptcha/index.js @@ -1,29 +1,16 @@ /* globals jQuery, grecaptcha, newspack_recaptcha_data */ +import { + addErrorMessage, + addHiddenV3Field, + destroyV3Field, + domReady, + getIntersectionObserver, + refreshV2Widget, + removeErrorMessages +} from './utils'; import './style.scss'; -/** - * Specify a function to execute when the DOM is fully loaded. - * - * @see https://github.com/WordPress/gutenberg/blob/trunk/packages/dom-ready/ - * - * @param {Function} callback A function to execute after the DOM is ready. - * @return {void} - */ -function domReady( callback ) { - if ( typeof document === 'undefined' ) { - return; - } - if ( - document.readyState === 'complete' || // DOMContentLoaded + Images/Styles/etc loaded, so we call directly. - document.readyState === 'interactive' // DOMContentLoaded fires at this point, so we call directly. - ) { - return void callback(); - } - // DOMContentLoaded has not fired yet, delay callback until then. - document.addEventListener( 'DOMContentLoaded', callback ); -} - window.newspack_grecaptcha = window.newspack_grecaptcha || { destroy: destroyV3Field, render, @@ -35,87 +22,6 @@ const isV3 = 'v3' === newspack_recaptcha_data.version; const siteKey = newspack_recaptcha_data.site_key; const isInvisible = 'v2_invisible' === newspack_recaptcha_data.version; -/** - * Destroy hidden reCAPTCHA v3 token fields to avoid unnecessary reCAPTCHA checks. - */ -function destroyV3Field( forms = [] ) { - if ( isV3 ) { - const formsToHandle = forms.length - ? forms - : [ ...document.querySelectorAll( 'form[data-newspack-recaptcha]' ) ]; - - formsToHandle.forEach( form => { - removeHiddenV3Field( form ); - } ); - } -} - -/** - * Refresh the reCAPTCHA v3 token for the given form and action. - * - * @param {HTMLElement} field The hidden input field storing the token for a form. - * @param {string} action The action name to pass to reCAPTCHA. - * - * @return {Promise|void} A promise that resolves when the token is refreshed. - */ -function refreshV3Token( field, action = 'submit' ) { - if ( field ) { - // Get a token to pass to the server. See https://developers.google.com/recaptcha/docs/v3 for API reference. - return grecaptcha.execute( siteKey, { action } ).then( token => { - field.value = token; - } ); - } -} - -/** - * Append a hidden reCAPTCHA v3 token field to the given form. - * - * @param {HTMLElement} form The form element. - */ -function addHiddenV3Field( form ) { - let field = form.querySelector( 'input[name="g-recaptcha-response"]' ); - if ( ! field ) { - field = document.createElement( 'input' ); - field.type = 'hidden'; - field.name = 'g-recaptcha-response'; - form.appendChild( field ); - - const action = form.getAttribute( 'data-newspack-recaptcha' ) || 'submit'; - refreshV3Token( field, action ); - setInterval( () => refreshV3Token( field, action ), 30000 ); // Refresh token every 30 seconds. - - // Refresh reCAPTCHAs on Woo checkout update and error. - if ( jQuery ) { - jQuery( document ).on( 'updated_checkout', () => refreshV3Token( field, action ) ); - jQuery( document.body ).on( 'checkout_error', () => refreshV3Token( field, action ) ); - } - } -} - -/** - * Remove the hidden reCAPTCHA v3 token field from the given form. - * - * @param {HTMLElement} form The form element. - */ -function removeHiddenV3Field( form ) { - const field = form.querySelector( 'input[name="g-recaptcha-response"]' ); - if ( field ) { - field.parentElement.removeChild( field ); - } -} - -/** - * Refresh the reCAPTCHA v2 widget attached to the given element. - * - * @param {HTMLElement} el Element with the reCAPTCHA widget to refresh. - */ -function refreshV2Widget( el ) { - const widgetId = parseInt( el.getAttribute( 'data-recaptcha-widget-id' ) ); - if ( ! isNaN( widgetId ) ) { - grecaptcha.reset( widgetId ); - } -} - /** * Render reCAPTCHA v2 widget on the given form. * @@ -139,14 +45,18 @@ function renderV2Widget( form, onSuccess = null, onError = null ) { if ( button.hasAttribute( 'data-skip-recaptcha' ) ) { return; } - // Refresh widget if it already exists. - if ( button.hasAttribute( 'data-recaptcha-widget-id' ) ) { - refreshV2Widget( button ); - return; - } // Callback when reCAPTCHA passes validation or skip flag is present. - const successCallback = () => { - onSuccess?.() + const successCallback = token => { + onSuccess?.(); + // Ensure the token gets submitted with the form submission. + let hiddenField = form.querySelector( '[name="g-recaptcha-response"]' ); + if ( ! hiddenField ) { + hiddenField = document.createElement( 'input' ); + hiddenField.type = 'hidden'; + hiddenField.name = 'g-recaptcha-response'; + form.appendChild( hiddenField ); + } + hiddenField.value = token; form.requestSubmit( button ); refreshV2Widget( button ); }; @@ -166,9 +76,40 @@ function renderV2Widget( form, onSuccess = null, onError = null ) { } } } + // Attach widget to form events. + const attachListeners = () => { + getIntersectionObserver( () => renderV2Widget( form, onSuccess, onError ) ).observe( form, { attributes: true } ); + button.addEventListener( 'click', e => { + e.preventDefault(); + e.stopImmediatePropagation(); + // Empty error messages if present. + removeErrorMessages( form ); + // Skip reCAPTCHA verification if the button has a data-skip-recaptcha attribute. + if ( button.hasAttribute( 'data-skip-recaptcha' ) ) { + successCallback(); + } else { + grecaptcha.execute( widgetId ).then( () => { + // If we are in an iframe scroll to top. + if ( window?.location !== window?.parent?.location ) { + document.body.scrollIntoView( { behavior: 'smooth' } ); + } + } ); + } + } ); + } + // Refresh reCAPTCHA widgets on Woo checkout update and error. + if ( jQuery ) { + jQuery( document ).on( 'updated_checkout', () => attachListeners ); + jQuery( document.body ).on( 'checkout_error', () => attachListeners ); + } + // Refresh widget if it already exists. + if ( button.hasAttribute( 'data-recaptcha-widget-id' ) ) { + refreshV2Widget( button ); + return; + } const container = document.createElement( 'div' ); container.classList.add( 'grecaptcha-container' ); - button.parentElement.append( container ); + document.body.append( container ); const widgetId = grecaptcha.render( container, { ...options, callback: successCallback, @@ -176,66 +117,10 @@ function renderV2Widget( form, onSuccess = null, onError = null ) { 'expired-callback': errorCallback, } ); button.setAttribute( 'data-recaptcha-widget-id', widgetId ); - - // Refresh reCAPTCHA widgets on Woo checkout update and error. - if ( jQuery ) { - jQuery( document ).on( 'updated_checkout', () => renderV2Widget( form, onSuccess, onError ) ); - jQuery( document.body ).on( 'checkout_error', () => renderV2Widget( form, onSuccess, onError ) ); - } - - button.addEventListener( 'click', e => { - e.preventDefault(); - e.stopImmediatePropagation(); - // Empty error messages if present. - removeErrorMessages( form ); - // Skip reCAPTCHA verification if the button has a data-skip-recaptcha attribute. - if ( button.hasAttribute( 'data-skip-recaptcha' ) ) { - successCallback(); - } else { - grecaptcha.execute( widgetId ).then( () => { - // If we are in an iframe scroll to top. - if ( window?.location !== window?.parent?.location ) { - document.body.scrollIntoView( { behavior: 'smooth' } ); - } - } ); - } - } ); + attachListeners(); } ); } -/** - * Append a generic error message above the given form. - * - * @param {HTMLElement} form The form element. - * @param {string} message The error message to display. - */ -function addErrorMessage( form, message ) { - const errorText = document.createElement( 'p' ); - errorText.textContent = message; - const container = document.createElement( 'div' ); - container.classList.add( 'newspack-recaptcha-error' ); - container.appendChild( errorText ); - // Newsletters block errors render below the form. - if ( form.parentElement.classList.contains( 'newspack-newsletters-subscribe' ) ) { - form.append( container ); - } else { - container.classList.add( 'newspack-ui__notice', 'newspack-ui__notice--error' ); - form.insertBefore( container, form.firstChild ); - } -} - -/** - * Remove generic error messages from form if present. - * - * @param {HTMLElement} form The form element. - */ -function removeErrorMessages( form ) { - const errors = form.querySelectorAll( '.newspack-recaptcha-error' ); - for ( const error of errors ) { - error.parentElement.removeChild( error ); - } -} - /** * Render reCAPTCHA elements. * @@ -254,25 +139,15 @@ function render( forms = [], onSuccess = null, onError = null ) { : [ ...document.querySelectorAll( 'form[data-newspack-recaptcha]' ) ]; formsToHandle.forEach( form => { - if ( ! form.hasAttribute( 'data-recaptcha-rendered' ) ) { - form.addEventListener( 'focusin', () => { - if ( isV2 ) { - renderV2Widget( form, onSuccess, onError ); - } - if ( isV3 ) { - addHiddenV3Field( form ); - } - } ); - form.setAttribute( 'data-recaptcha-rendered', 'true' ); - } else { - // Call render methods to trigger refresh. + const renderForm = () => { if ( isV2 ) { renderV2Widget( form, onSuccess, onError ); } if ( isV3 ) { addHiddenV3Field( form ); } - } + }; + getIntersectionObserver( renderForm ).observe( form, { attributes: true } ); } ); } diff --git a/src/other-scripts/recaptcha/utils.js b/src/other-scripts/recaptcha/utils.js new file mode 100644 index 0000000000..8c9d581625 --- /dev/null +++ b/src/other-scripts/recaptcha/utils.js @@ -0,0 +1,175 @@ +/* globals jQuery, grecaptcha, newspack_recaptcha_data */ + +// The minimum continuous amount of time an element must be in the viewport before being considered visible. +const MINIMUM_VISIBLE_TIME = 250; + +// The minimum percentage of an element that must be in the viewport before being considered visible. +const MINIMUM_VISIBLE_PERCENTAGE = 0.5; + +/** + * Specify a function to execute when the DOM is fully loaded. + * + * @see https://github.com/WordPress/gutenberg/blob/trunk/packages/dom-ready/ + * + * @param {Function} callback A function to execute after the DOM is ready. + * @return {void} + */ +export function domReady( callback ) { + if ( typeof document === 'undefined' ) { + return; + } + if ( + document.readyState === 'complete' || // DOMContentLoaded + Images/Styles/etc loaded, so we call directly. + document.readyState === 'interactive' // DOMContentLoaded fires at this point, so we call directly. + ) { + return void callback(); + } + // DOMContentLoaded has not fired yet, delay callback until then. + document.addEventListener( 'DOMContentLoaded', callback ); +} + +/** + * Create an IntersectionObserver to execute function `handleEvent` when an element becomes visible. + * + * @param {Function} handleEvent + * @return {IntersectionObserver} Observer instance. + */ +export function getIntersectionObserver( handleEvent ) { + let timer; + const observer = new IntersectionObserver( + entries => { + entries.forEach( observerEntry => { + if ( observerEntry.isIntersecting ) { + if ( ! timer ) { + timer = setTimeout( () => { + handleEvent(); + observer.unobserve( observerEntry.target ); + }, MINIMUM_VISIBLE_TIME || 0 ); + } + } else if ( timer ) { + clearTimeout( timer ); + timer = false; + } + } ); + }, + { + threshold: MINIMUM_VISIBLE_PERCENTAGE, + } + ); + + return observer; +}; + +/** + * Destroy hidden reCAPTCHA v3 token fields to avoid unnecessary reCAPTCHA checks. + */ +export function destroyV3Field( forms = [] ) { + const formsToHandle = forms.length + ? forms + : [ ...document.querySelectorAll( 'form[data-newspack-recaptcha]' ) ]; + + formsToHandle.forEach( form => { + removeHiddenV3Field( form ); + } ); +} + +/** + * Append a hidden reCAPTCHA v3 token field to the given form. + * + * @param {HTMLElement} form The form element. + */ +export function addHiddenV3Field( form ) { + let field = form.querySelector( 'input[name="g-recaptcha-response"]' ); + if ( ! field ) { + field = document.createElement( 'input' ); + field.type = 'hidden'; + field.name = 'g-recaptcha-response'; + form.appendChild( field ); + + const action = form.getAttribute( 'data-newspack-recaptcha' ) || 'submit'; + refreshV3Token( field, action ); + setInterval( () => refreshV3Token( field, action ), 30000 ); // Refresh token every 30 seconds. + + // Refresh reCAPTCHAs on Woo checkout update and error. + if ( jQuery ) { + jQuery( document ).on( 'updated_checkout', () => refreshV3Token( field, action ) ); + jQuery( document.body ).on( 'checkout_error', () => refreshV3Token( field, action ) ); + } + } +} + +/** + * Refresh the reCAPTCHA v3 token for the given form and action. + * + * @param {HTMLElement} field The hidden input field storing the token for a form. + * @param {string} action The action name to pass to reCAPTCHA. + * + * @return {Promise|void} A promise that resolves when the token is refreshed. + */ +function refreshV3Token( field, action = 'submit' ) { + if ( field ) { + const siteKey = newspack_recaptcha_data?.site_key; + + // Get a token to pass to the server. See https://developers.google.com/recaptcha/docs/v3 for API reference. + return grecaptcha.execute( siteKey, { action } ).then( token => { + field.value = token; + } ); + } +} + +/** + * Remove the hidden reCAPTCHA v3 token field from the given form. + * + * @param {HTMLElement} form The form element. + */ +function removeHiddenV3Field( form ) { + const field = form.querySelector( 'input[name="g-recaptcha-response"]' ); + if ( field ) { + field.parentElement.removeChild( field ); + } +} + +/** + * Refresh the reCAPTCHA v2 widget attached to the given element. + * + * @param {HTMLElement} el Element with the reCAPTCHA widget to refresh. + */ +export function refreshV2Widget( el ) { + const widgetId = parseInt( el.getAttribute( 'data-recaptcha-widget-id' ) ); + if ( ! isNaN( widgetId ) ) { + grecaptcha.reset( widgetId ); + } +} + +/** + * Append a generic error message above the given form. + * + * @param {HTMLElement} form The form element. + * @param {string} message The error message to display. + */ +export function addErrorMessage( form, message ) { + const errorText = document.createElement( 'p' ); + errorText.textContent = message; + const container = document.createElement( 'div' ); + container.classList.add( 'newspack-recaptcha-error' ); + container.appendChild( errorText ); + // Newsletters block errors render below the form. + if ( form.parentElement.classList.contains( 'newspack-newsletters-subscribe' ) ) { + form.append( container ); + } else { + container.classList.add( 'newspack-ui__notice', 'newspack-ui__notice--error' ); + form.insertBefore( container, form.firstChild ); + } +} + +/** + * Remove generic error messages from form if present. + * + * @param {HTMLElement} form The form element. + */ +export function removeErrorMessages( form ) { + const errors = form.querySelectorAll( '.newspack-recaptcha-error' ); + for ( const error of errors ) { + error.parentElement.removeChild( error ); + } +}