diff --git a/gravityforms-altcha.php b/gravityforms-altcha.php index 3dadbdf..8c21fed 100644 --- a/gravityforms-altcha.php +++ b/gravityforms-altcha.php @@ -6,7 +6,7 @@ * Plugin Name: ALTCHA for Gravity Forms * Plugin URI: https://github.com/generoi/gravityforms-altcha * Description: Invisible ALTCHA spam protection for Gravity Forms — uses the MIT-licensed altcha-org/altcha PHP library and the ALTCHA widget web component to proof-of-work-verify every form submission with no user interaction. - * Version: 0.5.0 + * Version: 0.6.0 * Requires at least: 6.0 * Requires PHP: 8.2 * Author: Genero diff --git a/resources/scripts/widget.js b/resources/scripts/widget.js index 74a2e4d..9334b4b 100644 --- a/resources/scripts/widget.js +++ b/resources/scripts/widget.js @@ -3,3 +3,89 @@ // the widget auto-solves the proof on load, and Gravity Forms posts the // resulting hidden input back to the server like any other field. import 'altcha'; + +/** + * Keeps the proof fresh for as long as the form stays open. + * + * The widget solves its proof once, on load, and the challenge the server + * issued is only valid for a limited window (see Challenge::DEFAULT_EXPIRES_ + * SECONDS). When that lapses the widget clears the solution and moves to the + * `expired` state — it does not re-solve on its own. On a long form (a product + * dropdown with hundreds of options, date pickers, a file upload) a visitor can + * easily still be typing by then, and submitting would fail validation with a + * form-level error that highlights no field, losing everything they entered. + * + * Re-solving on expiry closes that hole. `display="invisible"` means the + * visitor never sees any of it. + */ + +const BOUND_FLAG = 'gfAltchaRenew'; + +/** + * Fetches a brand new challenge and solves it. `verify()` re-reads the + * `challenge` attribute (a URL), so this issues a fresh challenge rather than + * re-solving the expired one. + * + * @param {HTMLElement} widget + */ +function renew(widget) { + // A backgrounded tab can't be submitting anything, so there's no point + // burning CPU on proof-of-work there. Wait until it's back on screen. + if (document.visibilityState === 'hidden') { + document.addEventListener( + 'visibilitychange', + () => renew(widget), + { once: true } + ); + + return; + } + + // Already grinding through a proof — a second call would race it. + if (widget.getState?.() === 'verifying') { + return; + } + + widget.verify?.(); +} + +/** + * @param {HTMLElement} widget + */ +function bind(widget) { + if (widget.dataset[BOUND_FLAG]) { + return; + } + widget.dataset[BOUND_FLAG] = '1'; + + // The widget dispatches `expired` on itself without `bubbles`, so the + // listener has to sit on each element rather than on the document. + widget.addEventListener('expired', () => renew(widget)); +} + +/** + * @param {ParentNode} root + */ +function bindAll(root) { + root.querySelectorAll?.('altcha-widget').forEach(bind); +} + +bindAll(document); + +// Gravity Forms re-renders the form markup after an AJAX submission or a page +// change in a multi-page form, which replaces the widget with a fresh element. +// Catch those so the replacement is covered by the same renewal. +new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node.nodeType !== Node.ELEMENT_NODE) { + continue; + } + if (node.tagName === 'ALTCHA-WIDGET') { + bind(node); + } else { + bindAll(node); + } + } + } +}).observe(document.documentElement, { childList: true, subtree: true }); diff --git a/src/Plugin.php b/src/Plugin.php index 1ee365c..2ecf637 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -4,7 +4,7 @@ class Plugin { - public const VERSION = '0.5.0'; + public const VERSION = '0.6.0'; public const SLUG = 'gravityforms-altcha';