Skip to content

Instantly share code, notes, and snippets.

@mcaskill
Last active June 7, 2023 17:05
Show Gist options
  • Save mcaskill/e38aa65658d04c683da1b88aa2c7b104 to your computer and use it in GitHub Desktop.
Save mcaskill/e38aa65658d04c683da1b88aa2c7b104 to your computer and use it in GitHub Desktop.
WP / NF: Fix and improve the handling of reCAPTCHA V2 in Ninja Forms. See README below.
/**
* Customization of Ninja Forms reCAPTCHA Field
*
* The following must replace its equivalent in 'assets/js/min/front-end-deps.js'. See diff file.
*/
nfRadio.channel( 'form' ).on( 'render:view', function() {
jQuery( '.g-recaptcha' ).each( function() {
var callback = jQuery( this ).data( 'callback' );
var fieldID = jQuery( this ).data( 'fieldid' );
if ( typeof window[ callback ] !== 'function' ){
window[ callback ] = function( response ) {
nfRecaptcha.DEBUG && console.group(callback);
nfRecaptcha.DEBUG && console.log('fieldID:', fieldID);
nfRecaptcha.DEBUG && console.log('response:', response);
nfRadio.channel( 'recaptcha' ).request( 'update:response', response, fieldID );
nfRadio.channel( 'captcha' ).request( 'update:response', response, fieldID );
nfRecaptcha.DEBUG && console.groupEnd();
};
}
} );
} );
var nfRecaptcha = Marionette.Object.extend( {
/** @var {boolean} - Validate only one reCAPTCHA at a time. */
isBusy: false,
/** @var {?{ninja-forms:FormModel}} - Track which form is submitting. */
isSubmitting: null,
initialize: function () {
/*
* If we've already rendered our form view, render our recaptcha fields.
*/
if ( 0 != jQuery( '.g-recaptcha' ).length ) {
this.renderCaptcha();
}
/*
* We haven't rendered our form view, so hook into the view render radio message, and then render.
*/
this.listenTo( nfRadio.channel( 'form' ), 'render:view', this.renderCaptcha );
this.listenTo( nfRadio.channel( 'captcha' ), 'reset', this.renderCaptcha );
this.listenTo( nfRadio.channel( 'submit' ), 'validate:field', this.validateCaptcha );
},
/**
* @param {ninja-forms:FormModel} formModel - The Ninja Forms form model.
*/
beforeSubmit: function ( formModel ) {
var DEBUG = nfRecaptcha.DEBUG;
DEBUG && console.group('nfRecaptcha.beforeSubmit');
this.isSubmitting = formModel;
DEBUG && console.log('Form:', formModel);
DEBUG && console.groupEnd();
},
renderCaptcha: function () {
var nfController = this,
DEBUG = nfRecaptcha.DEBUG;
DEBUG && console.group('nfRecaptcha.renderCaptcha');
jQuery( '.g-recaptcha:empty' ).each( function() {
DEBUG && console.group('.g-recaptcha', this);
var fieldID = jQuery( this ).data( 'fieldid' );
DEBUG && console.log( 'Field:', fieldID );
var opts = {
fieldid: fieldID,
size: jQuery( this ).data( 'size' ),
theme: jQuery( this ).data( 'theme' ),
sitekey: jQuery( this ).data( 'sitekey' ),
callback: jQuery( this ).data( 'callback' )
};
DEBUG && console.log( 'Size:', opts.size );
DEBUG && console.log( 'Callback:', opts.callback );
var grecaptchaID = grecaptcha.render( this, opts );
DEBUG && console.log( 'reCAPTCHA:', grecaptchaID );
if ( opts.size === 'invisible' ) {
var fieldModel = nfRadio.channel( 'fields' ).request( 'get:field', fieldID );
if ( ! fieldModel ) {
DEBUG && console.log( 'Skipping; Missing Field Model' );
DEBUG && console.groupEnd();
return;
}
fieldModel.set( 'grecaptchaID', grecaptchaID );
nfController.listenTo( nfRadio.channel( 'form-' + fieldModel.get( 'formID' ) ), 'before:submit', nfController.beforeSubmit );
nfRadio.channel( 'captcha' ).reply( 'update:response', nfController.updateValidation, nfController );
}
DEBUG && console.groupEnd();
} );
DEBUG && console.groupEnd();
},
/**
* @param {string} response - The reCAPTCHA response value.
* @param {string|number} fieldID - The Ninja Forms CAPTCHA field ID.
*/
updateValidation: function ( response, fieldID ) {
var DEBUG = nfRecaptcha.DEBUG;
DEBUG && console.group('nfRecaptcha.updateValidation');
var fieldModel = nfRadio.channel( 'fields' ).request( 'get:field', fieldID );
DEBUG && console.log('Field:', fieldModel);
if ( ! fieldModel ) {
DEBUG && console.log( 'Skipping; Missing Field Model' );
DEBUG && console.groupEnd();
return;
}
nfRadio.channel( 'fields' ).request( 'remove:error', fieldID, 'recaptcha-processing' );
var formModel = this.isSubmitting;
this.isSubmitting = null;
this.isBusy = false;
if ( formModel ) {
DEBUG && console.log( 'Attempting to re-submit form' );
nfRadio.channel( 'form-' + formModel.id ).request( 'submit', formModel );
} else {
DEBUG_CONTROLLER && console.log( 'Failed; Missing Form' );
}
DEBUG && console.groupEnd();
},
/**
* @param {NFFieldModel} fieldModel
*/
validateCaptcha: function ( fieldModel ) {
var DEBUG = nfRecaptcha.DEBUG;
DEBUG && console.group('nfRecaptcha.validateCaptcha --', 'Field:', fieldModel.id);
var fieldID = fieldModel.id;
var fieldType = fieldModel.get( 'type' );
var fieldSize = fieldModel.get( 'size' );
var grecaptchaID = fieldModel.get( 'grecaptchaID' );
if ( this.isBusy ) {
DEBUG && console.log( 'Skipping; Busy' );
DEBUG && console.groupEnd();
return;
}
if ( ! this.isSubmitting ) {
DEBUG && console.log( 'Skipping; Not Submitting' );
DEBUG && console.groupEnd();
return;
}
if (
fieldType !== 'recaptcha' ||
fieldSize !== 'invisible' ||
fieldModel.get( 'clean' )
) {
DEBUG && console.log( 'Skipping; Not reCAPTCHA Field' );
DEBUG && console.groupEnd();
return;
}
if ( grecaptchaID == null ) {
DEBUG && console.log( 'Skipping; Missing reCAPTCHA Widget ID' );
DEBUG && console.groupEnd();
return;
}
this.isBusy = true;
var fieldValue = fieldModel.get( 'value' );
var fieldOldValue = fieldModel.get( 'old_value' );
DEBUG && console.log( 'New Value:', fieldValue );
DEBUG && console.log( 'Old Value:', fieldOldValue );
nfRadio.channel( 'fields' ).request( 'remove:error', fieldID, 'required-error' );
if ( fieldOldValue != null && fieldValue ) {
fieldModel.set( 'old_value', null );
this.isBusy = false;
DEBUG && console.log( 'Validated: Same Value' );
DEBUG && console.groupEnd();
return;
}
fieldModel.set( 'old_value', fieldValue );
var formModel = nfRadio.channel( 'form-' + fieldModel.get( 'formID' ) ).request( 'get:form' );
if ( ! formModel ) {
this.isBusy = false;
DEBUG && console.log( 'Skipping; Missing Form Model' );
DEBUG && console.groupEnd();
return;
}
nfRadio.channel( 'fields' ).request(
'add:error',
fieldID,
'recaptcha-processing',
formModel.get('settings').recaptchaProcessing
);
try {
DEBUG && console.log( 'Attempting to execute reCAPTCHA' );
nf_reprocess_recaptcha( grecaptchaID );
} catch ( e ) {
this.isBusy = false;
console.log( 'Notice: Error trying to execute grecaptcha.' );
DEBUG && console.log( 'Caught error:', e );
}
DEBUG && console.groupEnd();
}
} );
/** @var {boolean} - Display debug information in console. */
nfRecaptcha.DEBUG = true;
--- assets/js/min/front-end-deps.js
+++ assets/js/min/front-end-deps.js
@@ -73,14 +73,26 @@
var fieldID = jQuery( this ).data( 'fieldid' );
if ( typeof window[ callback ] !== 'function' ){
window[ callback ] = function( response ) {
+ nfRecaptcha.DEBUG && console.group(callback);
+ nfRecaptcha.DEBUG && console.log('fieldID:', fieldID);
+ nfRecaptcha.DEBUG && console.log('response:', response);
+
nfRadio.channel( 'recaptcha' ).request( 'update:response', response, fieldID );
+ nfRadio.channel( 'captcha' ).request( 'update:response', response, fieldID );
+
+ nfRecaptcha.DEBUG && console.groupEnd();
};
}
} );
} );
var nfRecaptcha = Marionette.Object.extend( {
- initialize: function() {
+ /** @var {boolean} - Validate only one reCAPTCHA at a time. */
+ isBusy: false,
+ /** @var {?{ninja-forms:FormModel}} - Track which form is submitting. */
+ isSubmitting: null,
+
+ initialize: function () {
/*
* If we've already rendered our form view, render our recaptcha fields.
*/
@@ -91,33 +103,199 @@
* We haven't rendered our form view, so hook into the view render radio message, and then render.
*/
this.listenTo( nfRadio.channel( 'form' ), 'render:view', this.renderCaptcha );
- this.listenTo( nfRadio.channel( 'captcha' ), 'reset', this.renderCaptcha );
+ this.listenTo( nfRadio.channel( 'captcha' ), 'reset', this.renderCaptcha );
+ this.listenTo( nfRadio.channel( 'submit' ), 'validate:field', this.validateCaptcha );
+ },
+
+ /**
+ * @param {ninja-forms:FormModel} formModel - The Ninja Forms form model.
+ */
+ beforeSubmit: function ( formModel ) {
+ var DEBUG = nfRecaptcha.DEBUG;
+
+ DEBUG && console.group('nfRecaptcha.beforeSubmit');
+
+ this.isSubmitting = formModel;
+ DEBUG && console.log('Form:', formModel);
+
+ DEBUG && console.groupEnd();
},
- renderCaptcha: function() {
+ renderCaptcha: function () {
+ var nfController = this,
+ DEBUG = nfRecaptcha.DEBUG;
+
+ DEBUG && console.group('nfRecaptcha.renderCaptcha');
+
jQuery( '.g-recaptcha:empty' ).each( function() {
+ DEBUG && console.group('.g-recaptcha', this);
+
+ var fieldID = jQuery( this ).data( 'fieldid' );
+ DEBUG && console.log( 'Field:', fieldID );
+
var opts = {
- fieldid: jQuery( this ).data( 'fieldid' ),
+ fieldid: fieldID,
size: jQuery( this ).data( 'size' ),
theme: jQuery( this ).data( 'theme' ),
sitekey: jQuery( this ).data( 'sitekey' ),
callback: jQuery( this ).data( 'callback' )
};
- var grecaptchaID = grecaptcha.render( jQuery( this )[0], opts );
+ DEBUG && console.log( 'Size:', opts.size );
+ DEBUG && console.log( 'Callback:', opts.callback );
+
+ var grecaptchaID = grecaptcha.render( this, opts );
+ DEBUG && console.log( 'reCAPTCHA:', grecaptchaID );
if ( opts.size === 'invisible' ) {
- try {
- nf_reprocess_recaptcha( grecaptchaID );
- setInterval(nf_reprocess_recaptcha, 110000, grecaptchaID);
- } catch( e ){
- console.log( 'Notice: Error trying to execute grecaptcha.' );
+ var fieldModel = nfRadio.channel( 'fields' ).request( 'get:field', fieldID );
+
+ if ( ! fieldModel ) {
+ DEBUG && console.log( 'Skipping; Missing Field Model' );
+ DEBUG && console.groupEnd();
+ return;
}
- }
+
+ fieldModel.set( 'grecaptchaID', grecaptchaID );
+
+ nfController.listenTo( nfRadio.channel( 'form-' + fieldModel.get( 'formID' ) ), 'before:submit', nfController.beforeSubmit );
+
+ nfRadio.channel( 'captcha' ).reply( 'update:response', nfController.updateValidation, nfController );
+ }
+
+ DEBUG && console.groupEnd();
} );
+
+ DEBUG && console.groupEnd();
+ },
+
+ /**
+ * @param {string} response - The reCAPTCHA response value.
+ * @param {string|number} fieldID - The Ninja Forms CAPTCHA field ID.
+ */
+ updateValidation: function ( response, fieldID ) {
+ var DEBUG = nfRecaptcha.DEBUG;
+
+ DEBUG && console.group('nfRecaptcha.updateValidation');
+
+ var fieldModel = nfRadio.channel( 'fields' ).request( 'get:field', fieldID );
+ DEBUG && console.log('Field:', fieldModel);
+
+ if ( ! fieldModel ) {
+ DEBUG && console.log( 'Skipping; Missing Field Model' );
+ DEBUG && console.groupEnd();
+ return;
+ }
+
+ nfRadio.channel( 'fields' ).request( 'remove:error', fieldID, 'recaptcha-processing' );
+
+ var formModel = this.isSubmitting;
+
+ this.isSubmitting = null;
+ this.isBusy = false;
+
+ if ( formModel ) {
+ DEBUG && console.log( 'Attempting to re-submit form' );
+ nfRadio.channel( 'form-' + formModel.id ).request( 'submit', formModel );
+ } else {
+ DEBUG_CONTROLLER && console.log( 'Failed; Missing Form' );
+ }
+
+ DEBUG && console.groupEnd();
+ },
+
+ /**
+ * @param {NFFieldModel} fieldModel
+ */
+ validateCaptcha: function ( fieldModel ) {
+ var DEBUG = nfRecaptcha.DEBUG;
+
+ DEBUG && console.group('nfRecaptcha.validateCaptcha --', 'Field:', fieldModel.id);
+
+ var fieldID = fieldModel.id;
+ var fieldType = fieldModel.get( 'type' );
+ var fieldSize = fieldModel.get( 'size' );
+ var grecaptchaID = fieldModel.get( 'grecaptchaID' );
+
+ if ( this.isBusy ) {
+ DEBUG && console.log( 'Skipping; Busy' );
+ DEBUG && console.groupEnd();
+ return;
+ }
+
+ if ( ! this.isSubmitting ) {
+ DEBUG && console.log( 'Skipping; Not Submitting' );
+ DEBUG && console.groupEnd();
+ return;
+ }
+
+ if (
+ fieldType !== 'recaptcha' ||
+ fieldSize !== 'invisible' ||
+ fieldModel.get( 'clean' )
+ ) {
+ DEBUG && console.log( 'Skipping; Not reCAPTCHA Field' );
+ DEBUG && console.groupEnd();
+ return;
+ }
+
+ if ( grecaptchaID == null ) {
+ DEBUG && console.log( 'Skipping; Missing reCAPTCHA Widget ID' );
+ DEBUG && console.groupEnd();
+ return;
+ }
+
+ this.isBusy = true;
+
+ var fieldValue = fieldModel.get( 'value' );
+ var fieldOldValue = fieldModel.get( 'old_value' );
+ DEBUG && console.log( 'New Value:', fieldValue );
+ DEBUG && console.log( 'Old Value:', fieldOldValue );
+
+ nfRadio.channel( 'fields' ).request( 'remove:error', fieldID, 'required-error' );
+
+ if ( fieldOldValue != null && fieldValue ) {
+ fieldModel.set( 'old_value', null );
+
+ this.isBusy = false;
+ DEBUG && console.log( 'Validated: Same Value' );
+ DEBUG && console.groupEnd();
+ return;
+ }
+
+ fieldModel.set( 'old_value', fieldValue );
+
+ var formModel = nfRadio.channel( 'form-' + fieldModel.get( 'formID' ) ).request( 'get:form' );
+ if ( ! formModel ) {
+ this.isBusy = false;
+ DEBUG && console.log( 'Skipping; Missing Form Model' );
+ DEBUG && console.groupEnd();
+ return;
+ }
+
+ nfRadio.channel( 'fields' ).request(
+ 'add:error',
+ fieldID,
+ 'recaptcha-processing',
+ formModel.get('settings').recaptchaProcessing
+ );
+
+ try {
+ DEBUG && console.log( 'Attempting to execute reCAPTCHA' );
+ nf_reprocess_recaptcha( grecaptchaID );
+ } catch ( e ) {
+ this.isBusy = false;
+ console.log( 'Notice: Error trying to execute grecaptcha.' );
+ DEBUG && console.log( 'Caught error:', e );
+ }
+
+ DEBUG && console.groupEnd();
}
} );
+/** @var {boolean} - Display debug information in console. */
+nfRecaptcha.DEBUG = true;
+
var nfRenderRecaptcha = function() {
new nfRecaptcha();
}
<?php
/**
* Customization of Ninja Forms reCAPTCHA Field
*/
class FieldRecaptcha
{
public function boot() : void
{
add_filter( 'ninja_forms_display_form_settings', [ $this, 'filter_ninja_forms_display_form_settings' ], 10, 2 );
add_filter( 'ninja_forms_form_display_settings', [ $this, 'filter_ninja_forms_form_display_settings' ] );
}
/**
* Adds custom labeling settings to the pool of all form settings in Ninja Forms.
*
* @listens filter:ninja_forms_form_display_settings
*
* @param array<string, array<string, mixed>> $settings The form settings.
* @return array<string, array<string, mixed>>
*/
public function filter_ninja_forms_form_display_settings( array $settings ) : array {
if ( isset( $settings['custom_messages']['settings'] ) ) {
$settings['custom_messages']['settings'] = array_merge(
$settings['custom_messages']['settings'],
$this->get_messages_form_settings()
);
}
return $settings;
}
/**
* Adds any custom labels to form settings in Ninja Forms.
*
* @listens filter:ninja_forms_display_form_settings
*
* @param array<string, array<string, mixed>> $settings The form settings.
* @param int|string $form_id The form ID.
* @return array<string, array<string, mixed>>
*/
public function filter_ninja_forms_display_form_settings( array $settings, $form_id ) : array {
foreach ( $this->get_messages() as $name => $label ) {
if ( empty( $settings[ $name ] ) ) {
$settings[ $name ] = $label;
}
}
return $settings;
}
/**
* Returns an associative array of error/messages.
*
* @return array<string, string>
*/
public function get_messages() : array {
$messages = [
'recaptchaProcessing' => _x( 'Please wait, processing CAPTCHA field.', 'recaptcha field', 'nf-custom-recaptcha' ),
'recaptchaRequired' => _x( 'Please complete the CAPTCHA field.', 'recaptcha field', 'nf-custom-recaptcha' ),
'recaptchaMismatch' => _x( 'Please enter the correct value in the CAPTCHA field.', 'recaptcha field', 'nf-custom-recaptcha' ),
];
return $messages;
}
/**
* Retrieves custom validation form settings to Ninja Forms.
*
* @return array<string, array<string, mixed>>
*/
public function get_messages_form_settings() : array {
$settings = [];
foreach ( $this->get_messages() as $name => $label ) {
$setting = [
'name' => $name,
'type' => 'textbox',
'label' => esc_html( $label ),
'width' => 'full',
];
$settings[] = $setting;
}
return $settings;
}
}

Fixes the eager execution/challenge of reCAPTCHA V2 Invisible

When a reCAPTCHA field is configured as "invisible", Ninja Forms will execute grecaptcha.execute() when the form is rendered and in intervals of 110 seconds, regardless of the form's state. If reCAPTCHA determines a challenge is necessary, its appearance on page load is unexpected and confusing, and its potential recurrence is very annoying for users.

This patch replaces the interval callback with a more complex solution that involves interrupting the form submit with a temporary validation error (that displays "Processing…" error). When reCAPTCHA is done (checks and challenges completed), the temporary validation error is removed and the form is submitted to the server for final validation.

Ideally, Ninja Forms would implement support for a promise-based solution to allow for controllers to take their time with whatever needs to be processed.


THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment