Plugin Directory

Changeset 3714007


Ignore:
Timestamp:
09/26/2026 08:40:01 AM (3 weeks ago)
Author:
pluginos
Message:

new update

Location:
spamoff-bot-protection/tags/1.0.0
Files:
12 edited

Legend:

Unmodified
Added
Removed
  • spamoff-bot-protection/tags/1.0.0/assets/css/spamoff-bot-protection-style.css

    r3603369 r3714007  
    11/* =========================================================
    2    SpamOff – Spam & Bot Protection for WordPress — Checkout captcha styling
     2   SpamOff – Spam & Bot Protection for WordPress — captcha styling
    33   ========================================================= */
    44
    5 /* Remove box, background, border from the captcha wrapper */
     5.spamoff-bot-protection-v2 {
     6    margin: 12px 0 16px 0;
     7    clear: both;
     8}
     9
     10/* Google's notice, shown in forms when the v3 badge is hidden */
     11.spamoff-bot-protection-notice {
     12    margin: 8px 0;
     13    font-size: 0.8em;
     14    opacity: 0.8;
     15}
     16
     17/* Only added by the handler when the notice above is on the page, as Google requires */
     18.spamoff-bot-protection-hide-badge .grecaptcha-badge {
     19    visibility: hidden !important;
     20}
     21
     22/* Remove box, background, border from the checkout captcha wrapper */
    623.woocommerce-checkout .spamoff-bot-protection-container,
    724.woocommerce-checkout .spamoff-bot-protection-v2,
    … …  
    3754    box-shadow: none !important;
    3855}
    39 
    40 /* Disable old mobile scaling (handled via JS now) */
    41 @media (max-width: 768px) {
    42     .spamoff-bot-protection-v2 {
    43         transform: none !important;
    44     }
    45 }
  • spamoff-bot-protection/tags/1.0.0/assets/js/spamoff-bot-protection-blocks-checkout.js

    r3603369 r3714007  
    55 * where it's read server-side by SpamOff_Bot_Protection_Frontend::validate_blocks_checkout.
    66 *
    7  * Google v3 tokens expire after 120s. WC Blocks reads the token from the store at
    8  * submission time, so a token sitting idle for the customer's full checkout dwell
    9  * would be rejected as `timeout-or-duplicate`. We refresh on a timer (well inside
    10  * the TTL) and immediately on tab-focus, so the token in the store is always recent.
     7 * Google v3 tokens expire after 120s and are single-use. WC Blocks reads the token from
     8 * the store at submission time, so we refresh it on a timer (well inside the TTL), on
     9 * tab focus, and after every order attempt that doesn't complete — that attempt has
     10 * already spent the token on the server.
    1111 */
    1212(function () {
    … …  
    1414        return;
    1515    }
     16
     17    var STORE_KEY = 'wc/store/checkout';
     18    var NAMESPACE = 'spamoff-bot-protection';
    1619
    1720    // Refresh well before Google's 120s TTL.
    … …  
    2528
    2629    var initialized = false;
     30    var wasProcessing = false;
    2731    var refreshTimer = null;
     32
     33    function getSiteKey() {
     34        return window.spamoff_bot_protection && window.spamoff_bot_protection.v3SiteKey;
     35    }
    2836
    2937    function clearTimer() {
    … …  
    3543
    3644    function setExtensionToken(token) {
    37         wp.data
    38             .dispatch('wc/store/checkout')
    39             .setExtensionData('spamoff-bot-protection', {
    40                 token: token,
    41                 action: 'checkout',
    42             });
     45        wp.data.dispatch(STORE_KEY).setExtensionData(NAMESPACE, {
     46            token: token,
     47            action: 'checkout',
     48        });
    4349    }
    4450
    … …  
    4652        clearTimer();
    4753
    48         if (
    49             !window.spamoff_bot_protection ||
    50             !window.spamoff_bot_protection.siteKey ||
    51             typeof grecaptcha === 'undefined' ||
    52             !grecaptcha.ready
    53         ) {
     54        if (!getSiteKey() || typeof grecaptcha === 'undefined' || !grecaptcha.ready) {
    5455            refreshTimer = setTimeout(fetchAndSetToken, GRECAPTCHA_WAIT_MS);
    5556            return;
    … …  
    5758
    5859        grecaptcha.ready(function () {
    59             grecaptcha.execute(window.spamoff_bot_protection.siteKey, { action: 'checkout' })
     60            grecaptcha.execute(getSiteKey(), { action: 'checkout' })
    6061                .then(function (token) {
    6162                    setExtensionToken(token);
    … …  
    7071    }
    7172
    72     var unsubscribe = wp.data.subscribe(function () {
    73         if (initialized) return;
    74         if (!wp.data.select('wc/store/checkout')) return;
     73    wp.data.subscribe(function () {
     74        var checkout = wp.data.select(STORE_KEY);
     75        if (!checkout) {
     76            return;
     77        }
    7578
    76         initialized = true;
    77         if (typeof unsubscribe === 'function') {
    78             unsubscribe();
     79        if (!initialized) {
     80            initialized = true;
     81            fetchAndSetToken();
     82            return;
    7983        }
    80         fetchAndSetToken();
     84
     85        // An order attempt finished without completing (validation or payment error):
     86        // its token is spent, so get a new one before the customer retries.
     87        var processing = checkout.isProcessing() || checkout.isBeforeProcessing() || checkout.isAfterProcessing();
     88        if (wasProcessing && !processing && !checkout.isComplete()) {
     89            fetchAndSetToken();
     90        }
     91        wasProcessing = processing;
    8192    });
    8293
  • spamoff-bot-protection/tags/1.0.0/assets/js/spamoff-bot-protection-handler.js

    r3603369 r3714007  
    11/**
    22 * SpamOff – Spam & Bot Protection for WordPress — classic form handler.
    3  * Handles reCAPTCHA v2 and v3 token execution and form submission for non-Blocks forms.
     3 *
     4 * v3: when a protected form is submitted, fetch a token, then submit the form again
     5 *     with the button the visitor clicked, so handlers that check it (WooCommerce's
     6 *     `login` and `register`) still see it.
     7 * v2: render each checkbox explicitly so its widget ID is known and it can be reset
     8 *     after a failed classic checkout.
    49 */
    5 (function($) {
     10(function ($) {
    611    'use strict';
    712
    8     // Google v3 tokens expire after 120s. Treat anything older than this as stale
    9     // and re-execute on form submit, so a slow customer never sends an expired token.
     13    var config = window.spamoff_bot_protection || {};
     14
     15    // Google v3 tokens expire after 120s; fetch a new one past this age.
    1016    var TOKEN_FRESH_MS = 90 * 1000;
    1117
    12     function hasFreshToken($tokenField) {
    13         if (!$tokenField || !$tokenField.length) return false;
    14         if (!$tokenField.val()) return false;
    15         var fetchedAt = parseInt($tokenField.data('spamoff-bot-protection-fetched-at') || 0, 10);
    16         return (Date.now() - fetchedAt) < TOKEN_FRESH_MS;
    17     }
    18 
    19     function stampToken($tokenField, token) {
    20         $tokenField.val(token);
    21         $tokenField.data('spamoff-bot-protection-fetched-at', Date.now());
    22     }
    23 
    24     var SpamOffBotProtection = {
    25         init: function() {
    26             if (typeof spamoff_bot_protection === 'undefined') {
     18    // Give up waiting for Google's script (blocked by an extension, offline) after this.
     19    var READY_TIMEOUT_MS = 15 * 1000;
     20    var READY_POLL_MS = 100;
     21
     22    var readyCallbacks = null;
     23
     24    /**
     25     * Run onReady once grecaptcha is usable, or onFail if it never loads.
     26     */
     27    function whenReady(onReady, onFail) {
     28        if (window.grecaptcha && typeof window.grecaptcha.ready === 'function') {
     29            window.grecaptcha.ready(onReady);
     30            return;
     31        }
     32
     33        if (!readyCallbacks) {
     34            readyCallbacks = [];
     35            var waited = 0;
     36            var timer = setInterval(function () {
     37                waited += READY_POLL_MS;
     38                var loaded = window.grecaptcha && typeof window.grecaptcha.ready === 'function';
     39                if (!loaded && waited < READY_TIMEOUT_MS) {
     40                    return;
     41                }
     42                clearInterval(timer);
     43                var callbacks = readyCallbacks;
     44                readyCallbacks = null;
     45                callbacks.forEach(function (callback) {
     46                    if (loaded) {
     47                        window.grecaptcha.ready(callback.onReady);
     48                    } else if (callback.onFail) {
     49                        callback.onFail();
     50                    }
     51                });
     52            }, READY_POLL_MS);
     53        }
     54
     55        readyCallbacks.push({ onReady: onReady, onFail: onFail });
     56    }
     57
     58    /* ------------------------------------------------------------------ v3 */
     59
     60    function getTokenField(form) {
     61        return form.querySelector('input[name="spamoff_bot_protection_token"]');
     62    }
     63
     64    function hasFreshToken(field) {
     65        var fetchedAt = parseInt(field.getAttribute('data-fetched-at') || '0', 10);
     66        return field.value !== '' && Date.now() - fetchedAt < TOKEN_FRESH_MS;
     67    }
     68
     69    function getAction(field) {
     70        var container = field.closest('.spamoff-bot-protection-container');
     71        return (container && container.getAttribute('data-action')) || 'general';
     72    }
     73
     74    function defaultSubmitter(form) {
     75        return form.querySelector('button[type="submit"][name], input[type="submit"][name], button:not([type])[name]');
     76    }
     77
     78    /**
     79     * Submit the form again as if the visitor had clicked `submitter`.
     80     */
     81    function resubmit(form, submitter) {
     82        if (typeof form.requestSubmit === 'function') {
     83            // Fires the submit event again, so other handlers (e.g. WooCommerce's AJAX
     84            // checkout) still run, and includes the clicked button in the form data.
     85            try {
     86                form.requestSubmit(submitter && submitter.form === form ? submitter : undefined);
    2787                return;
    28             }
    29 
    30             if (spamoff_bot_protection.type === 'v3') {
    31                 this.initV3();
    32             } else if (spamoff_bot_protection.type === 'v2') {
    33                 this.initV2();
    34             }
    35         },
    36 
    37         initV3: function() {
    38             var self = this;
    39 
    40             if (typeof grecaptcha === 'undefined') {
    41                 setTimeout(function() {
    42                     self.initV3();
    43                 }, 100);
     88            } catch (e) {
     89                form.requestSubmit();
    4490                return;
    4591            }
    46 
    47             this.handleForm('form.login, form.woocommerce-form-login', 'login');
    48             this.handleForm('form.register, form.woocommerce-form-register', 'registration');
    49             this.handleCheckoutForm();
    50             this.handleForm('form.lost_reset_password, form.woocommerce-ResetPassword', 'lost_password');
    51             this.handleForm('form#commentform', 'comments');
    52 
    53             // Custom forms with shortcode.
    54             $('[id^="spamoff_bot_protection_token_"]').each(function() {
    55                 var action = $(this).attr('id').replace('spamoff_bot_protection_token_', '');
    56                 var form = $(this).closest('form');
    57                 if (form.length) {
    58                     self.handleForm(form, action);
     92        }
     93
     94        // Older browsers: forms submitted over AJAX by jQuery handlers.
     95        if ($(form).is('form.checkout')) {
     96            $(form).trigger('submit');
     97            return;
     98        }
     99
     100        // Carry the clicked button's name/value by hand, and call the prototype method
     101        // because a control named "submit" (WordPress comment form) shadows form.submit.
     102        if (submitter && submitter.name) {
     103            var hidden = document.createElement('input');
     104            hidden.type = 'hidden';
     105            hidden.name = submitter.name;
     106            hidden.value = submitter.value;
     107            form.appendChild(hidden);
     108        }
     109        HTMLFormElement.prototype.submit.call(form);
     110    }
     111
     112    /**
     113     * Capture-phase submit listener: runs before any handler bound on the form itself,
     114     * so a submission never leaves without a fresh token.
     115     */
     116    function onSubmit(event) {
     117        var form = event.target;
     118        if (!(form instanceof HTMLFormElement) || !config.v3SiteKey) {
     119            return;
     120        }
     121
     122        var field = getTokenField(form);
     123        if (!field) {
     124            return;
     125        }
     126
     127        // Token fetch failed: let the submission through; the server shows the error.
     128        if (form.getAttribute('data-spamoff-bot-protection-skip') === '1') {
     129            form.removeAttribute('data-spamoff-bot-protection-skip');
     130            return;
     131        }
     132
     133        if (hasFreshToken(field)) {
     134            // Tokens are single-use. Keep the value for this submission (payment gateways
     135            // may re-trigger it), but make the next one fetch a new token.
     136            field.setAttribute('data-fetched-at', '0');
     137            return;
     138        }
     139
     140        event.preventDefault();
     141        event.stopImmediatePropagation();
     142
     143        if (form.getAttribute('data-spamoff-bot-protection-pending') === '1') {
     144            return;
     145        }
     146        form.setAttribute('data-spamoff-bot-protection-pending', '1');
     147
     148        var submitter = event.submitter || defaultSubmitter(form);
     149
     150        function done(token) {
     151            form.removeAttribute('data-spamoff-bot-protection-pending');
     152            if (token) {
     153                field.value = token;
     154                field.setAttribute('data-fetched-at', String(Date.now()));
     155            } else {
     156                form.setAttribute('data-spamoff-bot-protection-skip', '1');
     157            }
     158            resubmit(form, submitter);
     159        }
     160
     161        whenReady(function () {
     162            window.grecaptcha.execute(config.v3SiteKey, { action: getAction(field) }).then(done, function () {
     163                done('');
     164            });
     165        }, function () {
     166            done('');
     167        });
     168    }
     169
     170    document.addEventListener('submit', onSubmit, true);
     171
     172    /* ------------------------------------------------------------------ v2 */
     173
     174    function renderV2Widgets() {
     175        var widgets = document.querySelectorAll('.spamoff-bot-protection-v2 .g-recaptcha[data-sitekey]:not([data-widget-id])');
     176        if (!widgets.length) {
     177            return;
     178        }
     179
     180        whenReady(function () {
     181            Array.prototype.forEach.call(widgets, function (el) {
     182                if (el.hasAttribute('data-widget-id')) {
     183                    return;
     184                }
     185                try {
     186                    var widgetId = window.grecaptcha.render(el, {
     187                        sitekey: el.getAttribute('data-sitekey'),
     188                        theme: el.getAttribute('data-theme') || 'light',
     189                        size: el.getAttribute('data-size') || 'normal'
     190                    });
     191                    el.setAttribute('data-widget-id', String(widgetId));
     192                } catch (e) {
     193                    // Already rendered.
    59194                }
    60195            });
    61         },
    62 
    63         initV2: function() {
    64             // v2 is handled automatically by Google's script after rendering the div.
    65         },
    66 
    67         handleCheckoutForm: function() {
    68             var $checkoutForm = $('form.checkout, form.woocommerce-checkout');
    69 
    70             if (!$checkoutForm.length) {
     196            fitV2Widgets();
     197        });
     198    }
     199
     200    function resetV2Widgets(scope) {
     201        if (!window.grecaptcha || typeof window.grecaptcha.reset !== 'function') {
     202            return;
     203        }
     204        Array.prototype.forEach.call(scope.querySelectorAll('.g-recaptcha[data-widget-id]'), function (el) {
     205            try {
     206                window.grecaptcha.reset(parseInt(el.getAttribute('data-widget-id'), 10));
     207            } catch (e) {
     208                // Widget gone.
     209            }
     210        });
     211    }
     212
     213    /**
     214     * Google's widget has a fixed width; scale it down when its column is narrower.
     215     */
     216    function fitV2Widgets() {
     217        Array.prototype.forEach.call(document.querySelectorAll('.spamoff-bot-protection-v2'), function (box) {
     218            var widget = box.querySelector('.g-recaptcha');
     219            if (!widget) {
    71220                return;
    72221            }
    73 
    74             // For v2, ensure the widget renders.
    75             if (spamoff_bot_protection.type === 'v2') {
    76                 setTimeout(function() {
    77                     if (typeof grecaptcha !== 'undefined' && grecaptcha.render) {
    78                         $('.g-recaptcha[data-sitekey="' + spamoff_bot_protection.siteKey + '"]').each(function() {
    79                             if (!$(this).attr('data-rendered')) {
    80                                 try {
    81                                     grecaptcha.render(this, {
    82                                         'sitekey': spamoff_bot_protection.siteKey,
    83                                         'theme': $(this).data('theme') || 'light',
    84                                         'size': $(this).data('size') || 'normal'
    85                                     });
    86                                     $(this).attr('data-rendered', 'true');
    87                                 } catch(e) {
    88                                     // noop
    89                                 }
    90                             }
    91                         });
    92                     }
    93                 }, 1000);
    94             }
    95 
    96             // Pre-execute a v3 token so quick submitters don't see a delay. Slow
    97             // submitters get a fresh token on click — see hasFreshToken() check below.
    98             // handleCheckoutForm() can re-run on every ajaxComplete, so skip the
    99             // pre-execute if a recent token is already in the field.
    100             if (spamoff_bot_protection.type === 'v3') {
    101                 var $tokenField = $('input[id^="spamoff_bot_protection_token_checkout"]');
    102                 var needsPreExecute = $tokenField.length
    103                     && !hasFreshToken($tokenField.first())
    104                     && typeof grecaptcha !== 'undefined'
    105                     && grecaptcha.ready;
    106                 if (needsPreExecute) {
    107                     grecaptcha.ready(function() {
    108                         grecaptcha.execute(spamoff_bot_protection.siteKey, {action: 'checkout'})
    109                             .then(function(token) {
    110                                 $tokenField.each(function() {
    111                                     stampToken($(this), token);
    112                                 });
    113                             })
    114                             .catch(function() {
    115                                 // noop — submit-time handler will retry.
    116                             });
    117                     });
    118                 }
    119             }
    120 
    121             // Place Order click: always ensure a fresh token. The previous version
    122             // only intercepted when the field was empty, so a stale pre-fetched
    123             // token (>120s old, Google's TTL) would be sent and rejected.
    124             $(document).off('click', '#place_order').on('click', '#place_order', function(e) {
    125                 if (spamoff_bot_protection.type !== 'v3') return;
    126 
    127                 var tokenField = $('input[id^="spamoff_bot_protection_token_checkout"]').first();
    128                 if (!tokenField.length) return;
    129                 if (hasFreshToken(tokenField)) return; // existing token is recent — let the submit proceed
    130 
    131                 e.preventDefault();
    132                 e.stopImmediatePropagation();
    133 
    134                 if (typeof grecaptcha !== 'undefined' && grecaptcha.ready) {
    135                     grecaptcha.ready(function() {
    136                         grecaptcha.execute(spamoff_bot_protection.siteKey, {action: 'checkout'})
    137                             .then(function(token) {
    138                                 stampToken(tokenField, token);
    139                                 $checkoutForm.submit();
    140                             })
    141                             .catch(function() {
    142                                 if (typeof wc_add_notice !== 'undefined') {
    143                                     wc_add_notice('Verification failed. Please try again.', 'error');
    144                                 }
    145                             });
    146                     });
    147                 }
    148                 return false;
    149             });
    150 
    151             // Also handle regular form submit as fallback (e.g. Enter key in a field).
    152             this.handleForm('form.checkout, form.woocommerce-checkout', 'checkout');
    153         },
    154 
    155         handleForm: function(selector, action) {
    156             var $form = $(selector);
    157 
    158             if (!$form.length) {
    159                 return;
    160             }
    161 
    162             if ($form.data('spamoff-bot-protection-handled')) {
    163                 return;
    164             }
    165 
    166             $form.data('spamoff-bot-protection-handled', true);
    167 
    168             // Execute the captcha on form submit. A token from page-load pre-execution
    169             // is reused only while still fresh (under Google's 120s TTL) — otherwise
    170             // we fetch a new one before letting the form submit.
    171             var submitHandler = function(e) {
    172                 var $formElement = $(this);
    173                 var tokenField = $formElement.find('input[id^="spamoff_bot_protection_token_' + action + '"]').first();
    174 
    175                 if (!tokenField.length) return;
    176                 if (hasFreshToken(tokenField)) return true;
    177 
    178                 e.preventDefault();
    179                 e.stopImmediatePropagation();
    180 
    181                 if (typeof grecaptcha !== 'undefined' && grecaptcha.ready) {
    182                     grecaptcha.ready(function() {
    183                         grecaptcha.execute(spamoff_bot_protection.siteKey, {action: action})
    184                             .then(function(token) {
    185                                 stampToken(tokenField, token);
    186                                 // Unbind only our handler so WooCommerce's submit handler remains.
    187                                 $formElement.off('submit', submitHandler).trigger('submit');
    188                             })
    189                             .catch(function() {
    190                                 $formElement.off('submit', submitHandler).trigger('submit');
    191                             });
    192                     });
    193                 } else {
    194                     $formElement.off('submit', submitHandler).trigger('submit');
    195                 }
    196 
    197                 return false;
    198             };
    199 
    200             $form.on('submit', submitHandler);
    201         }
    202     };
    203 
    204 
    205     // Initialize when document is ready.
    206     $(document).ready(function() {
    207         SpamOffBotProtection.init();
     222            var compact = widget.getAttribute('data-size') === 'compact';
     223            var width = compact ? 164 : 304;
     224            var height = compact ? 144 : 78;
     225            var available = box.clientWidth;
     226            var scale = available > 0 && available < width ? available / width : 1;
     227
     228            widget.style.transform = scale < 1 ? 'scale(' + scale + ')' : '';
     229            widget.style.transformOrigin = '0 0';
     230            box.style.height = scale < 1 ? Math.ceil(height * scale) + 'px' : '';
     231        });
     232    }
     233
     234    /* ------------------------------------------------------------- general */
     235
     236    /**
     237     * A theme that fires a form hook twice would put two captchas in one form, which
     238     * would post two tokens/responses under one name. Keep the first.
     239     */
     240    function removeDuplicateCaptchas() {
     241        Array.prototype.forEach.call(document.forms, function (form) {
     242            var containers = form.querySelectorAll('.spamoff-bot-protection-container');
     243            for (var i = 1; i < containers.length; i++) {
     244                containers[i].parentNode.removeChild(containers[i]);
     245            }
     246        });
     247    }
     248
     249    function init() {
     250        removeDuplicateCaptchas();
     251        renderV2Widgets();
     252
     253        // Google allows hiding the v3 badge only when its notice is shown instead.
     254        if (document.querySelector('.spamoff-bot-protection-notice')) {
     255            document.documentElement.classList.add('spamoff-bot-protection-hide-badge');
     256        }
     257    }
     258
     259    $(init);
     260
     261    // Forms added by AJAX (e.g. a theme's login popup).
     262    $(document).ajaxComplete(function () {
     263        removeDuplicateCaptchas();
     264        renderV2Widgets();
    208265    });
    209266
    210     // Also initialize on AJAX-loaded content (for WooCommerce checkout AJAX updates).
    211     $(document.body).on('updated_checkout updated_wc_div checkout_updated', function() {
    212         $('form.checkout, form.woocommerce-checkout').removeData('spamoff-bot-protection-handled');
    213         // Google tokens are one-time-use. Clear the stored token so the pre-execute
    214         // in handleCheckoutForm always fetches a fresh one after any checkout update
    215         // (including failed payment attempts where the old token was already consumed).
    216         $('input[id^="spamoff_bot_protection_token_checkout"]')
    217             .val('')
    218             .removeData('spamoff-bot-protection-fetched-at');
    219         // Reset v2 widget so the user doesn't resubmit an already-consumed response.
    220         if (typeof grecaptcha !== 'undefined' && window.spamoff_bot_protection && spamoff_bot_protection.type === 'v2') {
    221             try { grecaptcha.reset(); } catch(e) {}
    222         }
    223         setTimeout(function() {
    224             SpamOffBotProtection.init();
    225         }, 500);
     267    // A failed classic checkout has already spent the token/response on the server.
     268    // Clear it so the next attempt gets a new one. (Checkout refreshes that don't
     269    // submit the order leave a ticked v2 box alone.)
     270    $(document.body).on('checkout_error', function () {
     271        var form = document.querySelector('form.checkout');
     272        if (!form) {
     273            return;
     274        }
     275        Array.prototype.forEach.call(form.querySelectorAll('input[name="spamoff_bot_protection_token"]'), function (field) {
     276            field.value = '';
     277            field.setAttribute('data-fetched-at', '0');
     278        });
     279        resetV2Widgets(form);
    226280    });
    227281
    228     // Re-initialize after AJAX complete.
    229     $(document).ajaxComplete(function() {
    230         setTimeout(function() {
    231             SpamOffBotProtection.init();
    232         }, 100);
     282    var resizeTimer = null;
     283    $(window).on('resize', function () {
     284        clearTimeout(resizeTimer);
     285        resizeTimer = setTimeout(fitV2Widgets, 150);
    233286    });
    234 
    235     // Special handling for checkout page load: re-init, then trigger the v2 width-match resize.
    236     $(window).on('load', function() {
    237         if ($('body').hasClass('woocommerce-checkout')) {
    238             setTimeout(function() {
    239                 SpamOffBotProtection.init();
    240                 setTimeout(function() {
    241                     $(window).trigger('resize');
    242                 }, 400);
    243             }, 300);
    244         }
    245     });
    246 
    247     // Match the v2 reCAPTCHA widget width to the Place Order button via CSS transform.
    248     function matchRecaptchaToPlaceOrder() {
    249         var $recaptcha = $('.woocommerce-checkout .g-recaptcha');
    250         var $placeOrder = $('.woocommerce-checkout #place_order');
    251 
    252         if (!$recaptcha.length || !$placeOrder.length) return;
    253         if (!$recaptcha.find('iframe').length) return;
    254 
    255         var baseWidth = 304; // Google's fixed widget width
    256         // Cap at 1 — only scale DOWN for narrow buttons, never upscale beyond natural size.
    257         var scale = Math.min(1, $placeOrder.outerWidth() / baseWidth);
    258 
    259         $recaptcha.css({
    260             transform: 'scale(' + scale + ')',
    261             'transform-origin': 'left top',
    262             width: baseWidth + 'px',
    263             height: 'auto'
    264         });
    265     }
    266 
    267     $(document.body).on('updated_checkout updated_wc_div checkout_updated', function() {
    268         setTimeout(matchRecaptchaToPlaceOrder, 800);
    269     });
    270 
    271     $(window).on('resize', function() {
    272         setTimeout(matchRecaptchaToPlaceOrder, 300);
    273     });
    274 
    275287})(jQuery);
  • spamoff-bot-protection/tags/1.0.0/includes/class-spamoff-bot-protection-admin.php

    r3603369 r3714007  
    88    public function __construct() {
    99        add_filter( 'plugin_action_links_' . SPAMOFF_BOT_PROTECTION_PLUGIN_BASENAME, array( $this, 'add_settings_link' ) );
     10        add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_settings_script' ) );
     11        add_action( 'admin_init', array( $this, 'add_privacy_policy_content' ) );
     12    }
     13
     14    /**
     15     * Suggested text for the site's privacy policy (Settings → Privacy → Policy Guide),
     16     * since protected forms send visitor data to Google.
     17     */
     18    public function add_privacy_policy_content() {
     19        if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) {
     20            return;
     21        }
     22
     23        $content = '<p class="privacy-policy-tutorial">'
     24            . esc_html__( 'SpamOff adds Google reCAPTCHA to the forms you protect. On pages with a protected form, Google\'s reCAPTCHA script runs in the visitor\'s browser, and when the form is submitted your server sends the reCAPTCHA response and the visitor\'s IP address to Google to check it.', 'spamoff-bot-protection' )
     25            . '</p><p><strong class="privacy-policy-tutorial">' . esc_html__( 'Suggested text:', 'spamoff-bot-protection' ) . ' </strong>'
     26            . esc_html__( 'We use Google reCAPTCHA on our login, registration, password reset, checkout and comment forms to protect this site from spam and abuse. reCAPTCHA collects hardware and software information, such as device and application data, and sends it to Google for analysis. Your use of reCAPTCHA is subject to the Google Privacy Policy and Terms of Service.', 'spamoff-bot-protection' )
     27            . ' <a href="https://policies.google.com/privacy">https://policies.google.com/privacy</a>'
     28            . ' <a href="https://policies.google.com/terms">https://policies.google.com/terms</a></p>';
     29
     30        wp_add_privacy_policy_content( __( 'SpamOff – Google reCaptcha for WooCommerce', 'spamoff-bot-protection' ), wp_kses_post( $content ) );
    1031    }
    1132
    … …  
    1637        return $links;
    1738    }
     39
     40    /**
     41     * Show only the selected version's key fields on the General Settings screen.
     42     */
     43    public function enqueue_settings_script( $hook_suffix ) {
     44        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only screen detection.
     45        $is_settings_tab = 'woocommerce_page_wc-settings' === $hook_suffix
     46            && isset( $_GET['tab'] )
     47            && 'spamoff-bot-protection' === sanitize_key( wp_unslash( $_GET['tab'] ) );
     48        // phpcs:enable WordPress.Security.NonceVerification.Recommended
     49
     50        if ( ! $is_settings_tab ) {
     51            return;
     52        }
     53
     54        wp_enqueue_script(
     55            'spamoff-bot-protection-admin',
     56            SPAMOFF_BOT_PROTECTION_PLUGIN_URL . 'assets/js/spamoff-bot-protection-admin.js',
     57            array(),
     58            SPAMOFF_BOT_PROTECTION_PLUGIN_VERSION,
     59            array( 'in_footer' => true )
     60        );
     61
     62        wp_localize_script(
     63            'spamoff-bot-protection-admin',
     64            'spamoff_bot_protection_admin',
     65            array(
     66                // The Checkout block always uses the v3 keys, so keep them visible when it's in use.
     67                'alwaysShowV3' => SpamOff_Bot_Protection_Helper::checkout_page_uses_blocks() ? '1' : '',
     68            )
     69        );
     70    }
    1871}
  • spamoff-bot-protection/tags/1.0.0/includes/class-spamoff-bot-protection-captcha.php

    r3603369 r3714007  
    44}
    55
     6/**
     7 * Loads Google's reCAPTCHA script and the plugin's assets, only on pages that need them.
     8 *
     9 * Forms call require_type() as they render a captcha. Google's script is added in the
     10 * footer once the page knows which versions it rendered, so pages without a protected
     11 * form (including login pages whose toggle is off) never load it — and never show the
     12 * v3 badge.
     13 */
    614class SpamOff_Bot_Protection_Captcha {
    715
     16    /** @var array<string, bool> reCAPTCHA versions rendered on the current page. */
     17    private static $required_types = array();
     18
    819    public function __construct() {
    9         add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
     20        add_action( 'wp_enqueue_scripts', array( __CLASS__, 'register_assets' ), 5 );
     21        add_action( 'wp_enqueue_scripts', array( $this, 'maybe_load_everywhere' ) );
     22        // Just before footer scripts print, so forms output in wp_footer (login popups) count too.
     23        add_action( 'wp_print_footer_scripts', array( __CLASS__, 'enqueue_google_api' ), 1 );
     24
     25        // The Checkout block has no PHP form hook; detect it as it renders.
     26        add_filter( 'render_block_woocommerce/checkout', array( $this, 'on_checkout_block_render' ) );
    1027    }
    1128
    12     public function enqueue_scripts() {
    13         $site_key = SpamOff_Bot_Protection_Helper::get_option( 'spamoff_bot_protection_site_key' );
    14         if ( empty( $site_key ) ) {
     29    public static function register_assets() {
     30        if ( wp_script_is( 'spamoff-bot-protection-handler', 'registered' ) ) {
    1531            return;
    1632        }
    1733
    18         if ( is_admin() && ! wp_doing_ajax() ) {
    19             return;
    20         }
    21 
    22         // Blocks Checkout supports only v3.
    23         $recaptcha_type = SpamOff_Bot_Protection_Helper::get_option( 'spamoff_bot_protection_type', 'v3' );
    24         if ( SpamOff_Bot_Protection_Helper::is_blocks_checkout() ) {
    25             $recaptcha_type = 'v3';
    26         }
    27 
    28         $google_api_url = 'v3' === $recaptcha_type
    29             ? 'https://www.google.com/recaptcha/api.js?render=' . rawurlencode( $site_key )
    30             : 'https://www.google.com/recaptcha/api.js';
    31 
    32         wp_enqueue_script( 'spamoff-bot-protection-api', $google_api_url, array(), SPAMOFF_BOT_PROTECTION_PLUGIN_VERSION, true );
    33 
    34         wp_enqueue_script(
     34        wp_register_script(
    3535            'spamoff-bot-protection-handler',
    3636            SPAMOFF_BOT_PROTECTION_PLUGIN_URL . 'assets/js/spamoff-bot-protection-handler.js',
    37             array( 'jquery', 'spamoff-bot-protection-api' ),
     37            array( 'jquery' ),
    3838            SPAMOFF_BOT_PROTECTION_PLUGIN_VERSION,
    39             true
     39            array( 'in_footer' => true )
    4040        );
    4141
    42         wp_localize_script(
    43             'spamoff-bot-protection-handler',
    44             'spamoff_bot_protection',
    45             array(
    46                 'siteKey' => $site_key,
    47                 'type'    => $recaptcha_type,
    48             )
     42        wp_register_script(
     43            'spamoff-bot-protection-blocks-checkout',
     44            SPAMOFF_BOT_PROTECTION_PLUGIN_URL . 'assets/js/spamoff-bot-protection-blocks-checkout.js',
     45            array( 'wp-data' ),
     46            SPAMOFF_BOT_PROTECTION_PLUGIN_VERSION,
     47            array( 'in_footer' => true )
    4948        );
    5049
    51         if ( SpamOff_Bot_Protection_Helper::is_blocks_checkout() ) {
    52             wp_enqueue_script(
    53                 'spamoff-bot-protection-blocks-checkout',
    54                 SPAMOFF_BOT_PROTECTION_PLUGIN_URL . 'assets/js/spamoff-bot-protection-blocks-checkout.js',
    55                 array( 'wp-data', 'wp-hooks', 'spamoff-bot-protection-handler' ),
    56                 SPAMOFF_BOT_PROTECTION_PLUGIN_VERSION,
    57                 true
    58             );
    59         }
    60 
    61         wp_enqueue_style(
     50        wp_register_style(
    6251            'spamoff-bot-protection-style',
    6352            SPAMOFF_BOT_PROTECTION_PLUGIN_URL . 'assets/css/spamoff-bot-protection-style.css',
    … …  
    6655        );
    6756    }
     57
     58    /**
     59     * Mark a reCAPTCHA version as used on this page and enqueue the plugin's assets.
     60     *
     61     * Safe to call while the page body renders: scripts are printed in the footer.
     62     */
     63    public static function require_type( $type ) {
     64        self::register_assets();
     65
     66        self::$required_types[ $type ] = true;
     67
     68        wp_enqueue_style( 'spamoff-bot-protection-style' );
     69        wp_enqueue_script( 'spamoff-bot-protection-handler' );
     70    }
     71
     72    /**
     73     * Opt-in fallback for forms injected by AJAX after the page loads (e.g. login modals),
     74     * which render too late to enqueue scripts themselves.
     75     */
     76    public function maybe_load_everywhere() {
     77        /**
     78         * Filters whether reCAPTCHA assets load on every front-end page.
     79         *
     80         * @since 1.1.0
     81         *
     82         * @param bool $load_everywhere Default false: load only where a protected form renders.
     83         */
     84        if ( apply_filters( 'spamoff_bot_protection_load_everywhere', false ) && SpamOff_Bot_Protection_Helper::has_keys() ) {
     85            self::require_type( SpamOff_Bot_Protection_Helper::get_type() );
     86        }
     87    }
     88
     89    /**
     90     * Enqueue the Blocks Checkout token script when the Checkout block renders.
     91     *
     92     * @param string $block_content Rendered block HTML, returned unchanged.
     93     * @return string
     94     */
     95    public function on_checkout_block_render( $block_content ) {
     96        if ( SpamOff_Bot_Protection_Frontend::is_blocks_checkout_protected() ) {
     97            self::require_type( 'v3' );
     98            wp_enqueue_script( 'spamoff-bot-protection-blocks-checkout' );
     99        }
     100        return $block_content;
     101    }
     102
     103    /**
     104     * Add Google's script once the page has rendered every captcha it needs.
     105     *
     106     * v3 needs the site key in the URL (and shows the badge); v2 widgets are rendered
     107     * explicitly by the handler, which also works alongside a v3 script on the same page.
     108     */
     109    public static function enqueue_google_api() {
     110        if ( empty( self::$required_types ) ) {
     111            return;
     112        }
     113
     114        $needs_v3 = isset( self::$required_types['v3'] );
     115        $v3_key   = SpamOff_Bot_Protection_Helper::get_site_key( 'v3' );
     116
     117        if ( $needs_v3 && '' !== $v3_key ) {
     118            $google_api_url = add_query_arg( 'render', rawurlencode( $v3_key ), 'https://www.google.com/recaptcha/api.js' );
     119        } else {
     120            $needs_v3       = false;
     121            $google_api_url = add_query_arg( 'render', 'explicit', 'https://www.google.com/recaptcha/api.js' );
     122        }
     123
     124        wp_enqueue_script(
     125            'spamoff-bot-protection-api',
     126            $google_api_url,
     127            array(),
     128            SPAMOFF_BOT_PROTECTION_PLUGIN_VERSION,
     129            array(
     130                'in_footer' => true,
     131                'strategy'  => 'async',
     132            )
     133        );
     134
     135        $config = array(
     136            'v3SiteKey'     => $needs_v3 ? $v3_key : '',
     137            'failedMessage' => SpamOff_Bot_Protection_Helper::get_error_message( 'failed' ),
     138        );
     139
     140        foreach ( array( 'spamoff-bot-protection-handler', 'spamoff-bot-protection-blocks-checkout' ) as $handle ) {
     141            if ( wp_script_is( $handle, 'enqueued' ) ) {
     142                wp_localize_script( $handle, 'spamoff_bot_protection', $config );
     143            }
     144        }
     145    }
    68146}
  • spamoff-bot-protection/tags/1.0.0/includes/class-spamoff-bot-protection-frontend.php

    r3603369 r3714007  
    44}
    55
     6use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
     7use Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema;
     8use SpamOff_Bot_Protection_Helper as Helper;
     9
    610class SpamOff_Bot_Protection_Frontend {
    711
    8     /** Tracks which (action, suffix) pairs have already been rendered to avoid duplicates. */
    9     private static $rendered_actions = array();
     12    /** Store API extension namespace the Blocks Checkout script writes its token to. */
     13    const STORE_API_NAMESPACE = 'spamoff-bot-protection';
     14
     15    /** Captchas rendered per (action, suffix) on this page, for unique element IDs. */
     16    private static $render_counts = array();
    1017
    1118    public function __construct() {
    12         // Blocks Checkout (Store API): reads token from request.extensions and throws on failure.
    13         add_action( 'woocommerce_store_api_checkout_update_order_from_request', array( $this, 'validate_blocks_checkout' ), 10, 2 );
    14 
    1519        // Render captcha on WooCommerce / WP forms.
    1620        add_action( 'woocommerce_login_form', array( $this, 'add_login_captcha' ) );
    … …  
    2125        add_action( 'comment_form_logged_in_after', array( $this, 'add_comments_captcha' ) );
    2226
    23         // Server-side validation.
     27        // WordPress's own forms: wp-login.php, plus login forms made with wp_login_form().
     28        add_action( 'login_form', array( $this, 'add_wp_login_captcha' ) );
     29        add_filter( 'login_form_middle', array( $this, 'add_wp_login_form_captcha' ) );
     30        add_action( 'register_form', array( $this, 'add_wp_register_captcha' ) );
     31        add_action( 'lostpassword_form', array( $this, 'add_wp_lost_password_captcha' ) );
     32
     33        // Server-side validation. Each hook fires only for its own form, after WooCommerce
     34        // has checked that form's nonce.
    2435        add_filter( 'woocommerce_process_login_errors', array( $this, 'validate_login' ), 10, 3 );
    25         add_action( 'woocommerce_register_post', array( $this, 'validate_registration' ), 10, 3 );
    26         add_action( 'woocommerce_checkout_process', array( $this, 'validate_checkout' ), 1 );
    27         add_action( 'woocommerce_lostpassword_post', array( $this, 'validate_lost_password' ), 1 );
     36        add_filter( 'woocommerce_process_registration_errors', array( $this, 'validate_registration' ), 10, 4 );
     37        add_action( 'woocommerce_after_checkout_validation', array( $this, 'validate_checkout' ), 10, 2 );
     38        add_action( 'lostpassword_post', array( $this, 'validate_lost_password' ), 10, 2 );
    2839        add_action( 'pre_comment_on_post', array( $this, 'validate_comment' ) );
    2940
     41        // After WordPress checks the password (priority 20), so a failed captcha always wins.
     42        add_filter( 'authenticate', array( $this, 'validate_wp_login' ), 30 );
     43        add_filter( 'registration_errors', array( $this, 'validate_wp_registration' ) );
     44
     45        // Blocks Checkout (Store API): reads the token from request.extensions and throws on failure.
     46        add_action( 'init', array( $this, 'register_store_api_data' ) );
     47        add_action( 'woocommerce_store_api_checkout_update_order_from_request', array( $this, 'validate_blocks_checkout' ), 10, 2 );
     48
    3049        add_shortcode( 'spamoff_bot_protection', array( $this, 'shortcode' ) );
    3150    }
    3251
    3352    /**
    34      * Validate the captcha for WooCommerce Blocks Checkout.
    35      *
    36      * The Blocks JS injects the token via setExtensionData('spamoff-bot-protection', { token, action }),
    37      * which arrives here on the Store API request's `extensions` payload.
    38      */
    39     public function validate_blocks_checkout( $order, $request ) {
    40         if ( ! SpamOff_Bot_Protection_Helper::is_form_enabled( 'checkout' ) ) {
    41             return;
    42         }
    43 
    44         $extensions = is_array( $request['extensions'] ?? null ) ? $request['extensions'] : array();
    45         $data       = isset( $extensions['spamoff-bot-protection'] ) && is_array( $extensions['spamoff-bot-protection'] )
    46             ? $extensions['spamoff-bot-protection']
    47             : array();
    48 
    49         $token  = isset( $data['token'] )  ? sanitize_text_field( (string) $data['token'] )  : '';
    50         $action = isset( $data['action'] ) ? sanitize_text_field( (string) $data['action'] ) : 'checkout';
    51 
    52         if ( empty( $token ) ) {
    53             throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException(
    54                 'spamoff_bot_protection_missing',
    55                 esc_html( SpamOff_Bot_Protection_Helper::get_error_message( 'missing' ) ),
    56                 400
    57             );
    58         }
    59 
    60         $result = SpamOff_Bot_Protection_Helper::verify_recaptcha( $token, $action );
    61 
    62         if ( empty( $result['success'] ) || true !== $result['success'] ) {
    63             $error_type = isset( $result['error'] ) ? $result['error'] : 'failed';
    64             throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException(
    65                 'spamoff_bot_protection_failed',
    66                 esc_html( SpamOff_Bot_Protection_Helper::get_error_message( $error_type ) ),
    67                 400
    68             );
    69         }
     53     * Blocks Checkout protection. The Checkout block only supports v3, so it always
     54     * uses the v3 keys, whichever version is selected for the other forms.
     55     */
     56    public static function is_blocks_checkout_protected() {
     57        return Helper::is_checkout_enabled_for_current_user() && Helper::has_keys( 'v3' );
    7058    }
    7159
    7260    public function add_login_captcha() {
    73         if ( SpamOff_Bot_Protection_Helper::is_form_enabled( 'login' ) ) {
     61        if ( Helper::is_form_protected( 'login' ) ) {
    7462            $this->render_captcha( 'login' );
    7563        }
    … …  
    7765
    7866    public function add_register_captcha() {
    79         if ( SpamOff_Bot_Protection_Helper::is_form_enabled( 'registration' ) ) {
     67        if ( Helper::is_form_protected( 'registration' ) ) {
    8068            $this->render_captcha( 'registration' );
    8169        }
    … …  
    8371
    8472    public function add_checkout_captcha() {
    85         if ( ! SpamOff_Bot_Protection_Helper::is_form_enabled( 'checkout' ) ) {
    86             return;
    87         }
    88 
    89         if ( empty( SpamOff_Bot_Protection_Helper::get_option( 'spamoff_bot_protection_site_key' ) ) ) {
     73        if ( ! Helper::is_checkout_protected() ) {
    9074            return;
    9175        }
    … …  
    9781
    9882    public function add_lost_password_captcha() {
    99         if ( SpamOff_Bot_Protection_Helper::is_form_enabled( 'lost_password' ) ) {
     83        if ( Helper::is_form_protected( 'lost_password' ) ) {
    10084            $this->render_captcha( 'lost_password' );
    10185        }
    … …  
    10387
    10488    public function add_comments_captcha() {
    105         if ( SpamOff_Bot_Protection_Helper::is_form_enabled( 'comments' ) ) {
     89        if ( Helper::is_form_protected( 'comments' ) ) {
    10690            $this->render_captcha( 'comments' );
    10791        }
    10892    }
    10993
     94    public function add_wp_login_captcha() {
     95        if ( Helper::is_form_protected( 'wp_login' ) ) {
     96            $this->render_captcha( 'wp_login' );
     97        }
     98    }
     99
     100    /**
     101     * Login forms built with wp_login_form() (theme widgets, custom pages) post to
     102     * wp-login.php but don't fire the `login_form` action.
     103     *
     104     * @param string $content HTML inserted before the form's submit button.
     105     * @return string
     106     */
     107    public function add_wp_login_form_captcha( $content ) {
     108        if ( ! Helper::is_form_protected( 'wp_login' ) ) {
     109            return $content;
     110        }
     111
     112        ob_start();
     113        $this->render_captcha( 'wp_login' );
     114        return $content . ob_get_clean();
     115    }
     116
     117    public function add_wp_register_captcha() {
     118        if ( Helper::is_form_protected( 'wp_registration' ) ) {
     119            $this->render_captcha( 'wp_registration' );
     120        }
     121    }
     122
     123    public function add_wp_lost_password_captcha() {
     124        if ( Helper::is_form_protected( 'wp_lost_password' ) ) {
     125            $this->render_captcha( 'wp_lost_password' );
     126        }
     127    }
     128
    110129    public function validate_login( $validation_error, $username, $password ) {
    111         if ( ! SpamOff_Bot_Protection_Helper::is_form_enabled( 'login' ) ) {
     130        if ( ! Helper::is_form_protected( 'login' ) || ! $this->should_validate( 'login' ) ) {
    112131            return $validation_error;
    113132        }
    … …  
    121140    }
    122141
    123     public function validate_registration( $username, $email, $validation_errors ) {
    124         if ( ! SpamOff_Bot_Protection_Helper::is_form_enabled( 'registration' ) ) {
    125             return;
     142    /**
     143     * Runs only for the My Account registration form. (`woocommerce_register_post` also
     144     * fires for accounts created at checkout, in the admin and over REST, none of which
     145     * carry this captcha.)
     146     */
     147    public function validate_registration( $validation_error, $username, $password, $email ) {
     148        if ( ! Helper::is_form_protected( 'registration' ) || ! $this->should_validate( 'registration' ) ) {
     149            return $validation_error;
    126150        }
    127151
    128152        $result = $this->validate_captcha( 'registration' );
    129153        if ( is_wp_error( $result ) ) {
    130             $validation_errors->add( 'spamoff_bot_protection_error', $result->get_error_message() );
    131         }
    132     }
    133 
    134     public function validate_checkout() {
    135         if ( ! SpamOff_Bot_Protection_Helper::is_form_enabled( 'checkout' ) ) {
     154            $validation_error->add( 'spamoff_bot_protection_error', $result->get_error_message() );
     155        }
     156
     157        return $validation_error;
     158    }
     159
     160    /**
     161     * Classic checkout. Runs after WooCommerce's own field validation: Google tokens are
     162     * single-use, so spending one on an order that fails for another reason would make
     163     * the customer's corrected retry fail verification too.
     164     *
     165     * @param array    $data   Posted checkout data.
     166     * @param WP_Error $errors Checkout validation errors.
     167     */
     168    public function validate_checkout( $data, $errors ) {
     169        if ( ! Helper::is_checkout_protected() || ! $this->should_validate( 'checkout' ) ) {
     170            return;
     171        }
     172
     173        if ( $errors->has_errors() || wc_notice_count( 'error' ) > 0 ) {
    136174            return;
    137175        }
    … …  
    139177        $result = $this->validate_captcha( 'checkout' );
    140178        if ( is_wp_error( $result ) ) {
    141             wc_add_notice( $result->get_error_message(), 'error' );
    142         }
    143     }
    144 
    145     public function validate_lost_password() {
    146         if ( ! SpamOff_Bot_Protection_Helper::is_form_enabled( 'lost_password' ) ) {
    147             return;
    148         }
    149 
    150         $result = $this->validate_captcha( 'lost_password' );
    151         if ( is_wp_error( $result ) ) {
    152             wc_add_notice( $result->get_error_message(), 'error' );
    153         }
     179            $errors->add( 'spamoff_bot_protection_error', $result->get_error_message() );
     180        }
     181    }
     182
     183    /**
     184     * Both lost-password forms fire core's `lostpassword_post`: WooCommerce's (identified by
     185     * its hidden `wc_reset_password` field) and wp-login.php's. Each has its own toggle.
     186     * Other callers, such as an admin sending a reset link from the Users screen, have no
     187     * captcha and are left alone. The reset only stops when the error is added to `$errors`.
     188     *
     189     * @param WP_Error      $errors    Errors that stop the reset email.
     190     * @param WP_User|false $user_data User requesting the reset, if found.
     191     */
     192    public function validate_lost_password( $errors, $user_data = false ) {
     193        if ( ! is_wp_error( $errors ) ) {
     194            return;
     195        }
     196
     197        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Presence check only; validate_captcha() verifies the form nonce.
     198        if ( isset( $_POST['wc_reset_password'] ) ) {
     199            if ( ! Helper::is_form_protected( 'lost_password' ) || ! $this->should_validate( 'lost_password' ) ) {
     200                return;
     201            }
     202            $result = $this->validate_captcha( 'lost_password' );
     203        } elseif ( did_action( 'login_form_lostpassword' ) || did_action( 'login_form_retrievepassword' ) ) {
     204            if ( ! Helper::is_form_protected( 'wp_lost_password' ) || ! $this->should_validate( 'wp_lost_password' ) ) {
     205                return;
     206            }
     207            // wp-login.php's form has no nonce; the captcha is the check.
     208            $result = self::verify_posted_captcha( 'wp_lost_password' );
     209        } else {
     210            return;
     211        }
     212
     213        if ( is_wp_error( $result ) ) {
     214            $errors->add( 'spamoff_bot_protection_error', $result->get_error_message() );
     215        }
     216    }
     217
     218    /**
     219     * wp-login.php login. `authenticate` also runs for XML-RPC, application passwords,
     220     * WooCommerce's login and other plugins calling wp_signon(); none of those carry this
     221     * captcha, so only the wp-login.php login action is checked.
     222     *
     223     * @param WP_User|WP_Error|null $user Result of the earlier authentication checks.
     224     * @return WP_User|WP_Error|null
     225     */
     226    public function validate_wp_login( $user ) {
     227        // wp-login.php also runs authentication when the form is first displayed (GET).
     228        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Presence check only; wp-login.php's form has no nonce.
     229        if ( ! did_action( 'login_form_login' ) || ! isset( $_POST['log'] ) ) {
     230            return $user;
     231        }
     232
     233        if ( ! Helper::is_form_protected( 'wp_login' ) || ! $this->should_validate( 'wp_login' ) ) {
     234            return $user;
     235        }
     236
     237        $result = self::verify_posted_captcha( 'wp_login' );
     238        if ( is_wp_error( $result ) ) {
     239            return new WP_Error( 'spamoff_bot_protection_error', $result->get_error_message() );
     240        }
     241
     242        return $user;
     243    }
     244
     245    /**
     246     * wp-login.php registration. `registration_errors` also fires when other plugins call
     247     * register_new_user(), so only the wp-login.php register action is checked.
     248     *
     249     * @param WP_Error $errors Registration errors.
     250     * @return WP_Error
     251     */
     252    public function validate_wp_registration( $errors ) {
     253        if ( ! did_action( 'login_form_register' ) || ! is_wp_error( $errors ) ) {
     254            return $errors;
     255        }
     256
     257        if ( ! Helper::is_form_protected( 'wp_registration' ) || ! $this->should_validate( 'wp_registration' ) ) {
     258            return $errors;
     259        }
     260
     261        // wp-login.php's form has no nonce; the captcha is the check.
     262        $result = self::verify_posted_captcha( 'wp_registration' );
     263        if ( is_wp_error( $result ) ) {
     264            $errors->add( 'spamoff_bot_protection_error', $result->get_error_message() );
     265        }
     266
     267        return $errors;
    154268    }
    155269
    156270    public function validate_comment( $post_id ) {
    157         if ( ! SpamOff_Bot_Protection_Helper::is_form_enabled( 'comments' ) ) {
    158             return;
    159         }
    160 
    161         $result = $this->validate_captcha( 'comments' );
     271        if ( ! Helper::is_form_protected( 'comments' ) || ! $this->should_validate( 'comments' ) ) {
     272            return;
     273        }
     274
     275        // WordPress core puts no nonce on the comment form for visitors, so the
     276        // captcha itself is the check here.
     277        $result = self::verify_posted_captcha( 'comments' );
    162278        if ( is_wp_error( $result ) ) {
    163279            wp_die(
    164280                esc_html( $result->get_error_message() ),
    165281                esc_html__( 'Comment Submission Error', 'spamoff-bot-protection' ),
    166                 array( 'back_link' => true )
     282                array(
     283                    'response'  => 403,
     284                    'back_link' => true,
     285                )
    167286            );
    168287        }
    … …  
    170289
    171290    /**
    172      * Verify a captcha submission for a classic (non-Blocks) form.
    173      *
    174      * Always returns either WP_Error or true — never bare false — so callers
    175      * can rely on is_wp_error() to decide whether to block the submission.
     291     * Describe the Blocks Checkout's extension data so the Store API validates and
     292     * sanitizes it.
     293     */
     294    public function register_store_api_data() {
     295        if ( ! function_exists( 'woocommerce_store_api_register_endpoint_data' ) || ! class_exists( CheckoutSchema::class ) ) {
     296            return;
     297        }
     298
     299        woocommerce_store_api_register_endpoint_data(
     300            array(
     301                'endpoint'        => CheckoutSchema::IDENTIFIER,
     302                'namespace'       => self::STORE_API_NAMESPACE,
     303                'schema_callback' => array( $this, 'get_store_api_schema' ),
     304            )
     305        );
     306    }
     307
     308    public function get_store_api_schema() {
     309        $sanitize = function ( $value ) {
     310            return is_string( $value ) ? sanitize_text_field( $value ) : '';
     311        };
     312
     313        return array(
     314            'token'  => array(
     315                'description' => __( 'reCAPTCHA v3 token for the checkout.', 'spamoff-bot-protection' ),
     316                'type'        => array( 'string', 'null' ),
     317                'context'     => array(),
     318                'arg_options' => array( 'sanitize_callback' => $sanitize ),
     319            ),
     320            'action' => array(
     321                'description' => __( 'reCAPTCHA v3 action the token was created for.', 'spamoff-bot-protection' ),
     322                'type'        => array( 'string', 'null' ),
     323                'context'     => array(),
     324                'arg_options' => array( 'sanitize_callback' => $sanitize ),
     325            ),
     326        );
     327    }
     328
     329    /**
     330     * Validate the captcha for a Store API checkout (the Checkout block, or anything
     331     * else posting to /wc/store/v1/checkout).
     332     *
     333     * The Blocks JS writes the token with setExtensionData('spamoff-bot-protection', { token, action }).
     334     *
     335     * @param WC_Order        $order   Order being placed.
     336     * @param WP_REST_Request $request Store API request.
     337     * @throws RouteException When the captcha is missing or fails.
     338     */
     339    public function validate_blocks_checkout( $order, $request ) {
     340        // Only when the order is placed. WooCommerce also fires this hook on the PUT sent
     341        // while a customer edits a failed order; a token spent there would be rejected as
     342        // a duplicate when they place the order.
     343        if ( 'POST' !== $request->get_method() ) {
     344            return;
     345        }
     346
     347        if ( ! Helper::is_checkout_enabled_for_current_user() || ! $this->should_validate( 'checkout' ) ) {
     348            return;
     349        }
     350
     351        if ( ! Helper::has_keys( 'v3' ) ) {
     352            /**
     353             * Filters whether Store API checkouts are rejected while checkout protection is
     354             * on but no reCAPTCHA v3 keys are saved.
     355             *
     356             * The Store API only supports v3. Rejecting keeps bots from bypassing a v2-protected
     357             * classic checkout by posting to the Store API directly.
     358             *
     359             * @since 1.1.0
     360             *
     361             * @param bool $reject Default true.
     362             */
     363            if ( apply_filters( 'spamoff_bot_protection_store_api_requires_v3_keys', true ) ) {
     364                throw new RouteException(
     365                    'spamoff_bot_protection_not_configured',
     366                    esc_html( Helper::get_error_message( 'failed' ) ),
     367                    400
     368                );
     369            }
     370            return;
     371        }
     372
     373        $extensions = $request->get_param( 'extensions' );
     374        $data       = is_array( $extensions ) && isset( $extensions[ self::STORE_API_NAMESPACE ] ) && is_array( $extensions[ self::STORE_API_NAMESPACE ] )
     375            ? $extensions[ self::STORE_API_NAMESPACE ]
     376            : array();
     377
     378        $token = isset( $data['token'] ) && is_string( $data['token'] ) ? sanitize_text_field( $data['token'] ) : '';
     379
     380        if ( '' === $token ) {
     381            throw new RouteException(
     382                'spamoff_bot_protection_missing',
     383                esc_html( Helper::get_error_message( 'missing' ) ),
     384                400
     385            );
     386        }
     387
     388        $result = Helper::verify_recaptcha( $token, 'v3', 'checkout' );
     389
     390        if ( empty( $result['success'] ) ) {
     391            $error_type = isset( $result['error'] ) ? $result['error'] : 'failed';
     392            throw new RouteException(
     393                'spamoff_bot_protection_failed',
     394                esc_html( Helper::get_error_message( $error_type ) ),
     395                400
     396            );
     397        }
     398    }
     399
     400    /**
     401     * Check the WooCommerce form's nonce, then the posted captcha.
     402     *
     403     * WooCommerce has already verified the nonce before these hooks fire; checking it
     404     * again fails closed if another plugin calls the hooks outside the real form handler.
    176405     */
    177406    private function validate_captcha( $action ) {
    178         $nonce_error = new WP_Error( 'spamoff_bot_protection_nonce_invalid', SpamOff_Bot_Protection_Helper::get_error_message( 'failed' ) );
    179 
    180         // Re-verify the upstream form nonce. Failing closed (WP_Error) prevents
    181         // a bypass of WC/WP's own nonce check from silently letting us through.
    182         switch ( $action ) {
    183             case 'checkout':
    184                 if ( ! isset( $_POST['woocommerce-process-checkout-nonce'] ) ) {
    185                     return $nonce_error;
    186                 }
    187                 $nonce = sanitize_text_field( wp_unslash( $_POST['woocommerce-process-checkout-nonce'] ) );
    188                 if ( ! wp_verify_nonce( $nonce, 'woocommerce-process_checkout' ) ) {
    189                     return $nonce_error;
    190                 }
    191                 break;
    192 
    193             case 'login':
    194                 if ( ! isset( $_POST['woocommerce-login-nonce'] ) ) {
    195                     return $nonce_error;
    196                 }
    197                 $nonce = sanitize_text_field( wp_unslash( $_POST['woocommerce-login-nonce'] ) );
    198                 if ( ! wp_verify_nonce( $nonce, 'woocommerce-login' ) ) {
    199                     return $nonce_error;
    200                 }
    201                 break;
    202 
    203             case 'registration':
    204                 if ( ! isset( $_POST['woocommerce-register-nonce'] ) ) {
    205                     return $nonce_error;
    206                 }
    207                 $nonce = sanitize_text_field( wp_unslash( $_POST['woocommerce-register-nonce'] ) );
    208                 if ( ! wp_verify_nonce( $nonce, 'woocommerce-register' ) ) {
    209                     return $nonce_error;
    210                 }
    211                 break;
    212 
    213             case 'lost_password':
    214                 if ( ! isset( $_POST['woocommerce-lost-password-nonce'] ) ) {
    215                     return $nonce_error;
    216                 }
    217                 $nonce = sanitize_text_field( wp_unslash( $_POST['woocommerce-lost-password-nonce'] ) );
    218                 if ( ! wp_verify_nonce( $nonce, 'lost_password' ) ) {
    219                     return $nonce_error;
    220                 }
    221                 break;
    222 
    223             case 'comments':
    224                 if ( isset( $_POST['_wp_unfiltered_html_comment'], $_POST['comment_post_ID'] ) ) {
    225                     $post_id = absint( $_POST['comment_post_ID'] );
    226                     $nonce   = sanitize_text_field( wp_unslash( $_POST['_wp_unfiltered_html_comment'] ) );
    227                     if ( ! wp_verify_nonce( $nonce, 'unfiltered-html-comment_' . $post_id ) ) {
    228                         return $nonce_error;
    229                     }
    230                 } elseif ( isset( $_POST['_wpnonce'], $_POST['comment_post_ID'] ) ) {
    231                     $post_id = absint( $_POST['comment_post_ID'] );
    232                     $nonce   = sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) );
    233                     if ( ! wp_verify_nonce( $nonce, 'comment-post_' . $post_id ) ) {
    234                         return $nonce_error;
    235                     }
    236                 } else {
    237                     return $nonce_error;
    238                 }
    239                 break;
    240 
    241             default:
    242                 return $nonce_error;
    243         }
    244 
    245         $recaptcha_type = SpamOff_Bot_Protection_Helper::get_option( 'spamoff_bot_protection_type', 'v3' );
    246 
    247         if ( 'v3' === $recaptcha_type ) {
    248             $token        = isset( $_POST['spamoff_bot_protection_token'] )
    249                 ? sanitize_text_field( wp_unslash( $_POST['spamoff_bot_protection_token'] ) )
    250                 : '';
    251             $action_param = isset( $_POST['spamoff_bot_protection_action'] )
    252                 ? sanitize_text_field( wp_unslash( $_POST['spamoff_bot_protection_action'] ) )
    253                 : $action;
     407        if ( ! $this->verify_form_nonce( $action ) ) {
     408            return new WP_Error( 'spamoff_bot_protection_nonce_invalid', Helper::get_error_message( 'failed' ) );
     409        }
     410
     411        return self::verify_posted_captcha( $action );
     412    }
     413
     414    private function verify_form_nonce( $action ) {
     415        $nonces = array(
     416            'checkout'      => array( 'woocommerce-process-checkout-nonce', 'woocommerce-process_checkout' ),
     417            'login'         => array( 'woocommerce-login-nonce', 'woocommerce-login' ),
     418            'registration'  => array( 'woocommerce-register-nonce', 'woocommerce-register' ),
     419            'lost_password' => array( 'woocommerce-lost-password-nonce', 'lost_password' ),
     420        );
     421
     422        if ( ! isset( $nonces[ $action ] ) ) {
     423            return false;
     424        }
     425
     426        list( $field, $nonce_action ) = $nonces[ $action ];
     427
     428        // WooCommerce templates older than 3.3 used the generic `_wpnonce` field.
     429        if ( isset( $_POST[ $field ] ) ) {
     430            $nonce = sanitize_text_field( wp_unslash( $_POST[ $field ] ) );
     431        } elseif ( isset( $_POST['_wpnonce'] ) ) {
     432            $nonce = sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) );
    254433        } else {
    255             $token        = isset( $_POST['g-recaptcha-response'] )
    256                 ? sanitize_text_field( wp_unslash( $_POST['g-recaptcha-response'] ) )
    257                 : '';
    258             $action_param = '';
    259         }
    260 
    261         if ( empty( $token ) ) {
    262             return new WP_Error( 'spamoff_bot_protection_missing', SpamOff_Bot_Protection_Helper::get_error_message( 'missing' ) );
    263         }
    264 
    265         $result = SpamOff_Bot_Protection_Helper::verify_recaptcha( $token, $action_param );
    266 
    267         if ( empty( $result['success'] ) || true !== $result['success'] ) {
     434            return false;
     435        }
     436
     437        return false !== wp_verify_nonce( $nonce, $nonce_action );
     438    }
     439
     440    /**
     441     * Verify the captcha response posted with the current request.
     442     *
     443     * Callers must check the form's own nonce first, where the form has one.
     444     *
     445     * @param string $action Form action; for v3 the token must have been created for it.
     446     * @return true|WP_Error
     447     */
     448    public static function verify_posted_captcha( $action ) {
     449        $type   = Helper::get_type();
     450        $action = Helper::sanitize_action( $action );
     451
     452        // phpcs:disable WordPress.Security.NonceVerification.Missing -- Callers verify the form nonce; core's comment form has none.
     453        $field = 'v3' === $type ? 'spamoff_bot_protection_token' : 'g-recaptcha-response';
     454        $token = isset( $_POST[ $field ] ) && is_string( $_POST[ $field ] ) ? sanitize_text_field( wp_unslash( $_POST[ $field ] ) ) : '';
     455        // phpcs:enable WordPress.Security.NonceVerification.Missing
     456
     457        if ( '' === $token ) {
     458            return new WP_Error( 'spamoff_bot_protection_missing', Helper::get_error_message( 'missing' ) );
     459        }
     460
     461        $result = Helper::verify_recaptcha( $token, $type, 'v3' === $type ? $action : '' );
     462
     463        if ( empty( $result['success'] ) ) {
    268464            $error_type = isset( $result['error'] ) ? $result['error'] : 'failed';
    269             return new WP_Error( 'spamoff_bot_protection_failed', SpamOff_Bot_Protection_Helper::get_error_message( $error_type ) );
     465            return new WP_Error( 'spamoff_bot_protection_failed', Helper::get_error_message( $error_type ) );
    270466        }
    271467
    … …  
    274470
    275471    /**
     472     * Lets developers skip validation in special cases, such as an express-payment
     473     * button that places orders without the checkout form.
     474     */
     475    private function should_validate( $form ) {
     476        /**
     477         * Filters whether the captcha is checked for a form submission.
     478         *
     479         * @since 1.1.0
     480         *
     481         * @param bool   $should_validate Default true.
     482         * @param string $form            wp_login, wp_registration, wp_lost_password, comments, login,
     483         *                                registration, lost_password or checkout.
     484         */
     485        return (bool) apply_filters( 'spamoff_bot_protection_should_validate', true, $form );
     486    }
     487
     488    /**
    276489     * Render the captcha markup for a given action.
    277490     *
    278      * For v3 this is hidden inputs that the JS populates with a token.
    279      * For v2 it's the standard `g-recaptcha` div that Google's script auto-renders.
     491     * For v3 this is a hidden input that the JS fills with a token on submit.
     492     * For v2 it's a `g-recaptcha` div that the JS renders explicitly.
    280493     */
    281494    public function render_captcha( $action = 'general', $id_suffix = '' ) {
    282         $recaptcha_type = SpamOff_Bot_Protection_Helper::get_option( 'spamoff_bot_protection_type', 'v3' );
    283         $site_key       = SpamOff_Bot_Protection_Helper::get_option( 'spamoff_bot_protection_site_key' );
    284         $customization  = SpamOff_Bot_Protection_Helper::get_option( 'spamoff_bot_protection_customization', array() );
    285 
    286         if ( empty( $site_key ) ) {
    287             return;
    288         }
    289 
    290         $render_key = $action . ( $id_suffix ? '_' . $id_suffix : '' );
    291         if ( isset( self::$rendered_actions[ $render_key ] ) ) {
    292             return;
    293         }
    294         self::$rendered_actions[ $render_key ] = true;
    295 
    296         $id = $action . ( $id_suffix ? '_' . $id_suffix : '' );
    297 
    298         if ( 'v3' === $recaptcha_type ) {
     495        $type     = Helper::get_type();
     496        $site_key = Helper::get_site_key( $type );
     497
     498        if ( '' === $site_key ) {
     499            return;
     500        }
     501
     502        // Several forms of one kind can share a page (e.g. a theme's header login box on
     503        // the My Account page), so every call renders a captcha with its own ID. If a hook
     504        // fires twice inside one form, the handler keeps only the first captcha.
     505        $action = Helper::sanitize_action( $action );
     506        $base   = $action . ( $id_suffix ? '_' . $id_suffix : '' );
     507
     508        self::$render_counts[ $base ] = isset( self::$render_counts[ $base ] ) ? self::$render_counts[ $base ] + 1 : 1;
     509
     510        $count = self::$render_counts[ $base ];
     511        $id    = sanitize_html_class( 1 === $count ? $base : $base . '_' . $count );
     512
     513        SpamOff_Bot_Protection_Captcha::require_type( $type );
     514
     515        if ( 'v3' === $type ) {
    299516            echo '<div class="spamoff-bot-protection-container spamoff-bot-protection-v3" data-action="' . esc_attr( $action ) . '">';
    300517            echo '<input type="hidden" name="spamoff_bot_protection_token" id="spamoff_bot_protection_token_' . esc_attr( $id ) . '" value="">';
    301             echo '<input type="hidden" name="spamoff_bot_protection_action" value="' . esc_attr( $action ) . '">';
     518            if ( $this->is_badge_hidden() ) {
     519                $this->render_google_notice();
     520            }
    302521            echo '</div>';
    303         } elseif ( 'v2' === $recaptcha_type ) {
    304             $theme = isset( $customization['v2_theme'] ) ? $customization['v2_theme'] : 'light';
    305             $size  = isset( $customization['v2_size'] ) ? $customization['v2_size'] : 'normal';
    306 
    307             echo '<div class="spamoff-bot-protection-container spamoff-bot-protection-v2" data-action="' . esc_attr( $action ) . '" style="display: block !important; visibility: visible !important;">';
    308             echo '<div class="g-recaptcha" data-sitekey="' . esc_attr( $site_key ) . '" data-theme="' . esc_attr( $theme ) . '" data-size="' . esc_attr( $size ) . '" data-spamoff-bot-protection-id="' . esc_attr( $id ) . '" style="display: inline-block !important;"></div>';
    309             echo '</div>';
    310         }
     522            return;
     523        }
     524
     525        $customization = Helper::get_option( 'spamoff_bot_protection_customization', array() );
     526        $theme         = is_array( $customization ) && isset( $customization['v2_theme'] ) && 'dark' === $customization['v2_theme'] ? 'dark' : 'light';
     527        $size          = is_array( $customization ) && isset( $customization['v2_size'] ) && 'compact' === $customization['v2_size'] ? 'compact' : 'normal';
     528
     529        echo '<div class="spamoff-bot-protection-container spamoff-bot-protection-v2" data-action="' . esc_attr( $action ) . '">';
     530        echo '<div class="g-recaptcha" id="spamoff_bot_protection_widget_' . esc_attr( $id ) . '" data-sitekey="' . esc_attr( $site_key ) . '" data-theme="' . esc_attr( $theme ) . '" data-size="' . esc_attr( $size ) . '"></div>';
     531        echo '</div>';
     532    }
     533
     534    private function is_badge_hidden() {
     535        $customization = Helper::get_option( 'spamoff_bot_protection_customization', array() );
     536        return is_array( $customization ) && isset( $customization['hide_v3_badge'] ) && 'yes' === $customization['hide_v3_badge'];
     537    }
     538
     539    /**
     540     * Google allows hiding the v3 badge only if this notice appears in the user flow.
     541     */
     542    private function render_google_notice() {
     543        echo '<p class="spamoff-bot-protection-notice">';
     544        echo wp_kses(
     545            sprintf(
     546                /* translators: 1: Google Privacy Policy URL, 2: Google Terms of Service URL. */
     547                __( 'This site is protected by reCAPTCHA and the Google <a href="%1$s" target="_blank" rel="noopener noreferrer">Privacy Policy</a> and <a href="%2$s" target="_blank" rel="noopener noreferrer">Terms of Service</a> apply.', 'spamoff-bot-protection' ),
     548                'https://policies.google.com/privacy',
     549                'https://policies.google.com/terms'
     550            ),
     551            array(
     552                'a' => array(
     553                    'href'   => array(),
     554                    'target' => array(),
     555                    'rel'    => array(),
     556                ),
     557            )
     558        );
     559        echo '</p>';
    311560    }
    312561
    313562    public function shortcode( $atts ) {
    314         $atts   = shortcode_atts( array( 'action' => 'general' ), $atts, 'spamoff_bot_protection' );
    315         $action = $atts['action'];
    316 
    317         // SpamOff_Bot_Protection_Captcha::enqueue_scripts has already registered the real handles
    318         // on wp_enqueue_scripts; nudge them in case the shortcode is the only consumer.
    319         wp_enqueue_script( 'spamoff-bot-protection-api' );
    320         wp_enqueue_script( 'spamoff-bot-protection-handler' );
    321         wp_enqueue_style( 'spamoff-bot-protection-style' );
     563        $atts = shortcode_atts( array( 'action' => 'general' ), $atts, 'spamoff_bot_protection' );
     564
     565        if ( ! Helper::has_keys() ) {
     566            return '';
     567        }
    322568
    323569        ob_start();
    324         $this->render_captcha( $action );
     570        $this->render_captcha( $atts['action'] );
    325571        return ob_get_clean();
    326572    }
  • spamoff-bot-protection/tags/1.0.0/includes/class-spamoff-bot-protection-helper.php

    r3603369 r3714007  
    66class SpamOff_Bot_Protection_Helper {
    77
     8    /** Supported reCAPTCHA versions. Each keeps its own key pair. */
     9    const TYPES = array( 'v3', 'v2' );
     10
     11    /** Forms that can be toggled under Advanced Settings. The wp_* keys are WordPress's own wp-login.php forms. */
     12    const FORMS = array( 'wp_login', 'wp_registration', 'wp_lost_password', 'comments', 'login', 'registration', 'lost_password', 'checkout', 'guest_checkout' );
     13
    814    public static function get_option( $key, $default = '' ) {
    915        return get_option( $key, $default );
     16    }
     17
     18    /**
     19     * The reCAPTCHA version selected in General Settings.
     20     */
     21    public static function get_type() {
     22        $type = self::get_option( 'spamoff_bot_protection_type', 'v3' );
     23        return in_array( $type, self::TYPES, true ) ? $type : 'v3';
     24    }
     25
     26    public static function get_site_key( $type = null ) {
     27        $type = self::normalize_type( $type );
     28        return trim( (string) self::get_option( 'spamoff_bot_protection_' . $type . '_site_key', '' ) );
     29    }
     30
     31    public static function get_secret_key( $type = null ) {
     32        $type = self::normalize_type( $type );
     33        return trim( (string) self::get_option( 'spamoff_bot_protection_' . $type . '_secret_key', '' ) );
     34    }
     35
     36    /**
     37     * Both keys for a version are saved. Without them the captcha can't render or
     38     * verify, so forms are left unprotected rather than locking every visitor out.
     39     */
     40    public static function has_keys( $type = null ) {
     41        return '' !== self::get_site_key( $type ) && '' !== self::get_secret_key( $type );
    1042    }
    1143
    … …  
    1648
    1749    /**
    18      * Check if reCAPTCHA is enabled for a given form key (login, checkout, …).
     50     * Check if reCAPTCHA is switched on for a given form key (login, checkout, …).
    1951     */
    2052    public static function is_form_enabled( $form ) {
    … …  
    2456
    2557    /**
     58     * Switched on and usable with the selected version's keys.
     59     */
     60    public static function is_form_protected( $form ) {
     61        return self::is_form_enabled( $form ) && self::has_keys();
     62    }
     63
     64    /**
     65     * Whether the current checkout needs a captcha, before looking at keys.
     66     *
     67     * "Checkout" covers every customer; "Guest Checkout" covers only visitors who
     68     * aren't logged in.
     69     */
     70    public static function is_checkout_enabled_for_current_user() {
     71        if ( self::is_form_enabled( 'checkout' ) ) {
     72            return true;
     73        }
     74        return self::is_form_enabled( 'guest_checkout' ) && ! is_user_logged_in();
     75    }
     76
     77    /**
     78     * Classic checkout protection, using the selected version.
     79     */
     80    public static function is_checkout_protected() {
     81        return self::is_checkout_enabled_for_current_user() && self::has_keys();
     82    }
     83
     84    /**
    2685     * Verify a reCAPTCHA token with Google's siteverify endpoint.
    27      */
    28     public static function verify_recaptcha( $token, $action = '' ) {
    29         $secret_key = self::get_option( 'spamoff_bot_protection_secret_key' );
    30 
    31         if ( empty( $secret_key ) || empty( $token ) ) {
     86     *
     87     * @param string      $token           Token (v3) or g-recaptcha-response (v2) from the client.
     88     * @param string|null $type            'v3' or 'v2'; defaults to the selected version.
     89     * @param string      $expected_action v3 action the token must have been created for. Empty skips the check.
     90     * @return array Google's response with `success` => true, or `success` => false and an `error` code.
     91     */
     92    public static function verify_recaptcha( $token, $type = null, $expected_action = '' ) {
     93        $type       = self::normalize_type( $type );
     94        $secret_key = self::get_secret_key( $type );
     95
     96        if ( '' === $secret_key ) {
    3297            return array(
    3398                'success' => false,
    3499                'error'   => 'missing_credentials',
    35100            );
     101        }
     102
     103        if ( empty( $token ) ) {
     104            return array(
     105                'success' => false,
     106                'error'   => 'missing',
     107            );
     108        }
     109
     110        $body = array(
     111            'secret'   => $secret_key,
     112            'response' => $token,
     113        );
     114
     115        $remote_ip = self::get_user_ip();
     116        if ( '' !== $remote_ip ) {
     117            $body['remoteip'] = $remote_ip;
    36118        }
    37119
    … …  
    39121            'https://www.google.com/recaptcha/api/siteverify',
    40122            array(
    41                 'body'    => array(
    42                     'secret'   => $secret_key,
    43                     'response' => $token,
    44                     'remoteip' => self::get_user_ip(),
    45                 ),
     123                'body'    => $body,
    46124                'timeout' => 10,
    47125            )
    … …  
    56134        }
    57135
    58         $body = wp_remote_retrieve_body( $response );
    59         $data = json_decode( $body, true );
     136        $data = json_decode( wp_remote_retrieve_body( $response ), true );
    60137
    61138        if ( ! is_array( $data ) ) {
    … …  
    67144
    68145        if ( ! isset( $data['success'] ) || true !== $data['success'] ) {
    69             $error_codes = isset( $data['error-codes'] ) ? $data['error-codes'] : array( 'unknown_error' );
    70146            return array(
    71147                'success'     => false,
    72148                'error'       => 'verification_failed',
    73                 'error_codes' => $error_codes,
    74             );
    75         }
    76 
    77         // For v3, enforce the configured minimum score.
    78         $recaptcha_type = self::get_option( 'spamoff_bot_protection_type', 'v3' );
    79         if ( 'v3' === $recaptcha_type ) {
     149                'error_codes' => isset( $data['error-codes'] ) ? (array) $data['error-codes'] : array( 'unknown_error' ),
     150            );
     151        }
     152
     153        // The token must have been issued for this site, not harvested from another one.
     154        if ( ! empty( $data['hostname'] ) && ! in_array( strtolower( $data['hostname'] ), self::get_allowed_hostnames(), true ) ) {
     155            return array(
     156                'success'  => false,
     157                'error'    => 'hostname_mismatch',
     158                'hostname' => $data['hostname'],
     159            );
     160        }
     161
     162        if ( 'v3' === $type ) {
     163            // A token minted for one form (e.g. comments) must not unlock another (e.g. checkout).
     164            if ( '' !== $expected_action && isset( $data['action'] ) && $data['action'] !== $expected_action ) {
     165                return array(
     166                    'success' => false,
     167                    'error'   => 'action_mismatch',
     168                    'action'  => $data['action'],
     169                );
     170            }
     171
    80172            $score         = isset( $data['score'] ) ? floatval( $data['score'] ) : 0.0;
    81173            $minimum_score = self::sanitize_score( self::get_option( 'spamoff_bot_protection_minimum_score', '0.5' ) );
    … …  
    97189
    98190    /**
    99      * Resolve the visitor's IP, preferring proxy headers when present.
     191     * Hostnames Google may report for a valid token on this site.
     192     */
     193    public static function get_allowed_hostnames() {
     194        $hosts = array();
     195        foreach ( array( home_url(), site_url() ) as $url ) {
     196            $host = wp_parse_url( $url, PHP_URL_HOST );
     197            if ( $host ) {
     198                $host    = strtolower( $host );
     199                $hosts[] = $host;
     200                $hosts[] = 0 === strpos( $host, 'www.' ) ? substr( $host, 4 ) : 'www.' . $host;
     201            }
     202        }
     203
     204        // Google's published test keys report this hostname.
     205        $hosts[] = 'testkey.google.com';
     206
     207        /**
     208         * Filters the hostnames accepted in Google's verification response.
     209         *
     210         * Add every domain the protected forms are served from, e.g. for multi-domain setups.
     211         *
     212         * @since 1.1.0
     213         *
     214         * @param string[] $hosts Lowercase hostnames.
     215         */
     216        $hosts = apply_filters( 'spamoff_bot_protection_allowed_hostnames', $hosts );
     217
     218        return array_values( array_unique( array_map( 'strtolower', (array) $hosts ) ) );
     219    }
     220
     221    /**
     222     * The visitor's IP address, sent to Google as an optional hint.
     223     *
     224     * Only REMOTE_ADDR is trusted: proxy headers can be forged by the client. Sites
     225     * behind a trusted proxy or CDN can supply the real address through the filter.
    100226     */
    101227    public static function get_user_ip() {
    102         $ip_keys = array( 'HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR' );
    103 
    104         foreach ( $ip_keys as $key ) {
    105             if ( isset( $_SERVER[ $key ] ) ) {
    106                 foreach ( explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) ) as $ip ) {
    107                     $ip = trim( $ip );
    108                     if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) !== false ) {
    109                         return $ip;
    110                     }
    111                 }
    112             }
    113         }
    114 
    115         return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
     228        $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
     229
     230        /**
     231         * Filters the visitor IP address sent to Google's siteverify endpoint.
     232         *
     233         * @since 1.1.0
     234         *
     235         * @param string $ip IP address from REMOTE_ADDR.
     236         */
     237        $ip = (string) apply_filters( 'spamoff_bot_protection_remote_ip', $ip );
     238
     239        return false !== filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
    116240    }
    117241
    118242    /**
    119243     * Get the error message shown to the user for a given failure type.
     244     *
     245     * The customizable message covers failed checks; a missing captcha keeps its own
     246     * wording so v2 users know they still need to tick the box.
    120247     */
    121248    public static function get_error_message( $error_type = 'failed' ) {
     249        if ( 'missing' === $error_type ) {
     250            return __( 'Please complete the verification.', 'spamoff-bot-protection' );
     251        }
     252
    122253        $customization = self::get_option( 'spamoff_bot_protection_customization', array() );
    123254
    124         if ( isset( $customization['error_message_failed'] ) && '' !== $customization['error_message_failed'] ) {
     255        if ( is_array( $customization ) && isset( $customization['error_message_failed'] ) && '' !== $customization['error_message_failed'] ) {
    125256            return $customization['error_message_failed'];
    126257        }
    127258
    128         $messages = array(
    129             'failed'    => __( 'Verification failed. Please try again.', 'spamoff-bot-protection' ),
    130             'missing'   => __( 'Please complete the verification.', 'spamoff-bot-protection' ),
    131             'low_score' => __( 'Verification failed. Please try again.', 'spamoff-bot-protection' ),
    132         );
    133 
    134         return isset( $messages[ $error_type ] ) ? $messages[ $error_type ] : $messages['failed'];
    135     }
    136 
    137     /**
    138      * Check if the current request is rendering the WooCommerce Blocks Checkout.
    139      */
    140     public static function is_blocks_checkout() {
    141         if ( ! function_exists( 'has_block' ) ) {
     259        return __( 'Verification failed. Please try again.', 'spamoff-bot-protection' );
     260    }
     261
     262    /**
     263     * Whether the page configured as the WooCommerce checkout uses the Checkout block.
     264     *
     265     * Used for admin warnings only; the front end detects the block as it renders.
     266     */
     267    public static function checkout_page_uses_blocks() {
     268        if ( ! function_exists( 'has_block' ) || ! function_exists( 'wc_get_page_id' ) ) {
    142269            return false;
    143270        }
    144         // Check the configured checkout page directly — more reliable than the global
    145         // $post during wp_enqueue_scripts, where some caching plugins may not set it.
    146         $checkout_page_id = function_exists( 'wc_get_page_id' ) ? wc_get_page_id( 'checkout' ) : 0;
    147         if ( $checkout_page_id > 0 ) {
    148             return has_block( 'woocommerce/checkout', $checkout_page_id );
    149         }
    150         return has_block( 'woocommerce/checkout' );
     271        $checkout_page_id = wc_get_page_id( 'checkout' );
     272        return $checkout_page_id > 0 && has_block( 'woocommerce/checkout', $checkout_page_id );
     273    }
     274
     275    /**
     276     * Google action names allow only letters, numbers, slashes and underscores.
     277     */
     278    public static function sanitize_action( $action ) {
     279        $action = preg_replace( '/[^A-Za-z0-9_\/]/', '_', (string) $action );
     280        return '' === $action ? 'general' : $action;
     281    }
     282
     283    private static function normalize_type( $type ) {
     284        return in_array( $type, self::TYPES, true ) ? $type : self::get_type();
    151285    }
    152286}
     287
     288if ( ! function_exists( 'spamoff_bot_protection_verify_submission' ) ) {
     289    /**
     290     * Verify the captcha posted by a custom form that uses the [spamoff_bot_protection] shortcode.
     291     *
     292     * Call it from the form's own submission handler, after checking the form's nonce.
     293     *
     294     * @since 1.1.0
     295     *
     296     * @param string $action The `action` attribute given to the shortcode (default "general").
     297     * @return true|WP_Error True when verified, WP_Error with a user-facing message otherwise.
     298     */
     299    function spamoff_bot_protection_verify_submission( $action = 'general' ) {
     300        return SpamOff_Bot_Protection_Frontend::verify_posted_captcha( $action );
     301    }
     302}
  • spamoff-bot-protection/tags/1.0.0/includes/class-spamoff-bot-protection-settings.php

    r3603369 r3714007  
    44}
    55
     6use SpamOff_Bot_Protection_Helper as Helper;
     7
    68class SpamOff_Bot_Protection_Settings {
    79
    … …  
    3234        $this->output_settings_tabs();
    3335        echo '</div>';
     36
     37        $this->output_configuration_notices();
    3438
    3539        echo '<div class="spamoff-bot-protection-settings-content">';
    … …  
    6165    }
    6266
     67    /**
     68     * Warn about settings that leave forms unprotected or would reject orders.
     69     */
     70    private function output_configuration_notices() {
     71        $notices = array();
     72        $type    = Helper::get_type();
     73        $forms   = get_option( 'spamoff_bot_protection_forms', array() );
     74        $any_on  = is_array( $forms ) && in_array( 'yes', $forms, true );
     75
     76        if ( $any_on && ! Helper::has_keys( $type ) ) {
     77            $notices[] = sprintf(
     78                /* translators: %s: reCAPTCHA version, e.g. v3. */
     79                __( 'Protection is off: add the reCAPTCHA %s Site Key and Secret Key under General Settings.', 'spamoff-bot-protection' ),
     80                $type
     81            );
     82        }
     83
     84        $checkout_on = Helper::is_form_enabled( 'checkout' ) || Helper::is_form_enabled( 'guest_checkout' );
     85        if ( $checkout_on && ! Helper::has_keys( 'v3' ) && Helper::checkout_page_uses_blocks() ) {
     86            $notices[] = __( 'Your checkout page uses the Checkout block, which only supports reCAPTCHA v3. Orders placed there are rejected until you save v3 keys. Select v3 under General Settings to enter them; they stay saved if you switch back to v2.', 'spamoff-bot-protection' );
     87        }
     88
     89        foreach ( $notices as $notice ) {
     90            echo '<div class="notice notice-warning inline"><p>' . esc_html( $notice ) . '</p></div>';
     91        }
     92    }
     93
    6394    public function output_settings_content( $section ) {
    6495        switch ( $section ) {
    … …  
    96127    }
    97128
     129    /**
     130     * Each version keeps its own key pair, so switching between v3 and v2 never
     131     * loses the other version's keys. The admin script shows the selected version's rows.
     132     */
    98133    public function get_general_settings() {
    99134        return array(
    … …  
    101136                'name' => __( 'General Settings', 'spamoff-bot-protection' ),
    102137                'type' => 'title',
    103                 'desc' => __( 'Configure your basic reCAPTCHA settings and API keys.', 'spamoff-bot-protection' ),
     138                'desc' => __( 'Configure your basic reCAPTCHA settings and API keys.', 'spamoff-bot-protection' ) . ' '
     139                    . __( 'Protected forms send visitor data to Google reCAPTCHA; suggested privacy-policy text is under Settings → Privacy → Policy Guide.', 'spamoff-bot-protection' ),
    104140                'id'   => 'spamoff_bot_protection_general_title',
    105141            ),
    106142            array(
    107143                'name'    => __( 'reCAPTCHA Type', 'spamoff-bot-protection' ),
    108                 'desc'    => __( 'Choose between reCAPTCHA v3 (invisible) and v2 (checkbox).', 'spamoff-bot-protection' ),
     144                'desc'    => __( 'Choose between reCAPTCHA v3 (invisible) and v2 (checkbox). Google issues separate keys for each; both sets stay saved when you switch.', 'spamoff-bot-protection' ),
    109145                'id'      => 'spamoff_bot_protection_type',
    110146                'type'    => 'select',
    … …  
    116152            ),
    117153            array(
    118                 'name' => __( 'Site Key', 'spamoff-bot-protection' ),
    119                 'desc' => __( 'Enter your Google reCAPTCHA site key.', 'spamoff-bot-protection' ),
    120                 'id'   => 'spamoff_bot_protection_site_key',
    121                 'type' => 'text',
    122                 'css'  => 'min-width: 400px;',
    123             ),
    124             array(
    125                 'name' => __( 'Secret Key', 'spamoff-bot-protection' ),
    126                 'desc' => __( 'Enter your Google reCAPTCHA secret key.', 'spamoff-bot-protection' ),
    127                 'id'   => 'spamoff_bot_protection_secret_key',
    128                 'type' => 'password',
    129                 'css'  => 'min-width: 400px;',
    130             ),
    131             array(
    132                 'name'    => __( 'Minimum Score for reCAPTCHA v3', 'spamoff-bot-protection' ),
    133                 'desc'    => __( 'Minimum score required (0.0 - 1.0). Submissions below this score will be rejected.', 'spamoff-bot-protection' ),
    134                 'id'      => 'spamoff_bot_protection_minimum_score',
    135                 'type'    => 'number',
    136                 'default' => '0.5',
    137                 'step'    => '0.1',
    138                 'min'     => '0.0',
    139                 'max'     => '1.0',
    140                 'css'     => 'width: 80px;',
     154                'name'      => __( 'v3 Site Key', 'spamoff-bot-protection' ),
     155                'desc'      => __( 'Enter your Google reCAPTCHA v3 site key. The Checkout block always uses the v3 keys.', 'spamoff-bot-protection' ),
     156                'id'        => 'spamoff_bot_protection_v3_site_key',
     157                'type'      => 'text',
     158                'css'       => 'min-width: 400px;',
     159                'row_class' => 'spamoff-bot-protection-v3',
     160            ),
     161            array(
     162                'name'              => __( 'v3 Secret Key', 'spamoff-bot-protection' ),
     163                'desc'              => __( 'Enter your Google reCAPTCHA v3 secret key.', 'spamoff-bot-protection' ),
     164                'id'                => 'spamoff_bot_protection_v3_secret_key',
     165                'type'              => 'password',
     166                'css'               => 'min-width: 400px;',
     167                'row_class'         => 'spamoff-bot-protection-v3',
     168                'custom_attributes' => array( 'autocomplete' => 'new-password' ),
     169            ),
     170            array(
     171                'name'      => __( 'Minimum Score for reCAPTCHA v3', 'spamoff-bot-protection' ),
     172                'desc'      => __( 'Minimum score required (0.0 - 1.0). Submissions below this score will be rejected.', 'spamoff-bot-protection' ),
     173                'id'        => 'spamoff_bot_protection_minimum_score',
     174                'type'      => 'number',
     175                'default'   => '0.5',
     176                'css'       => 'width: 80px;',
     177                'row_class' => 'spamoff-bot-protection-v3',
     178                'custom_attributes' => array(
     179                    'step' => '0.1',
     180                    'min'  => '0',
     181                    'max'  => '1',
     182                ),
     183            ),
     184            array(
     185                'name'      => __( 'v2 Site Key', 'spamoff-bot-protection' ),
     186                'desc'      => __( 'Enter your Google reCAPTCHA v2 ("I\'m not a robot" Checkbox) site key.', 'spamoff-bot-protection' ),
     187                'id'        => 'spamoff_bot_protection_v2_site_key',
     188                'type'      => 'text',
     189                'css'       => 'min-width: 400px;',
     190                'row_class' => 'spamoff-bot-protection-v2',
     191            ),
     192            array(
     193                'name'              => __( 'v2 Secret Key', 'spamoff-bot-protection' ),
     194                'desc'              => __( 'Enter your Google reCAPTCHA v2 secret key.', 'spamoff-bot-protection' ),
     195                'id'                => 'spamoff_bot_protection_v2_secret_key',
     196                'type'              => 'password',
     197                'css'               => 'min-width: 400px;',
     198                'row_class'         => 'spamoff-bot-protection-v2',
     199                'custom_attributes' => array( 'autocomplete' => 'new-password' ),
    141200            ),
    142201            array(
    … …  
    185244    }
    186245
     246    /**
     247     * Labels for the Advanced Settings toggles, keyed like Helper::FORMS.
     248     */
     249    private function get_form_labels() {
     250        return array(
     251            'wp_login'         => __( 'WordPress Login (wp-login.php)', 'spamoff-bot-protection' ),
     252            'wp_registration'  => __( 'WordPress Registration (wp-login.php)', 'spamoff-bot-protection' ),
     253            'wp_lost_password' => __( 'WordPress Lost Password (wp-login.php)', 'spamoff-bot-protection' ),
     254            'comments'         => __( 'WordPress Comments', 'spamoff-bot-protection' ),
     255            'login'            => __( 'WooCommerce Login (My Account and checkout)', 'spamoff-bot-protection' ),
     256            'registration'     => __( 'WooCommerce Registration (My Account)', 'spamoff-bot-protection' ),
     257            'lost_password'    => __( 'WooCommerce Lost Password (My Account)', 'spamoff-bot-protection' ),
     258            'checkout'         => __( 'WooCommerce Checkout: all customers', 'spamoff-bot-protection' ),
     259            'guest_checkout'   => __( 'WooCommerce Checkout: guests only', 'spamoff-bot-protection' ),
     260        );
     261    }
     262
    187263    public function output_forms_table() {
    188264        $forms = get_option( 'spamoff_bot_protection_forms', array() );
    … …  
    190266            $forms = array();
    191267        }
    192 
    193         $form_list = array(
    194             'login'          => __( 'Customer Login', 'spamoff-bot-protection' ),
    195             'registration'   => __( 'Customer Registration', 'spamoff-bot-protection' ),
    196             'checkout'       => __( 'Checkout Page', 'spamoff-bot-protection' ),
    197             'guest_checkout' => __( 'Guest Checkout', 'spamoff-bot-protection' ),
    198             'lost_password'  => __( 'Lost Password', 'spamoff-bot-protection' ),
    199             'comments'       => __( 'WordPress Comments', 'spamoff-bot-protection' ),
    200         );
    201268        ?>
    202         <tr valign="top">
     269        <tr>
    203270            <th scope="row" class="titledesc"><?php esc_html_e( 'Protected Forms', 'spamoff-bot-protection' ); ?></th>
    204271            <td class="forminp">
    … …  
    211278                    </thead>
    212279                    <tbody>
    213                         <?php foreach ( $form_list as $key => $label ) :
    214                             $checked = ( isset( $forms[ $key ] ) && 'yes' === $forms[ $key ] ) ? 'checked' : '';
    215                         ?>
     280                        <?php foreach ( $this->get_form_labels() as $key => $label ) : ?>
    216281                            <tr>
    217                                 <td style="padding: 10px;"><?php echo esc_html( $label ); ?></td>
     282                                <td style="padding: 10px;"><label for="spamoff_bot_protection_forms_<?php echo esc_attr( $key ); ?>"><?php echo esc_html( $label ); ?></label></td>
    218283                                <td style="padding: 10px;">
    219                                     <input type="checkbox" name="spamoff_bot_protection_forms[<?php echo esc_attr( $key ); ?>]" value="yes" <?php echo esc_attr( $checked ); ?>>
     284                                    <input type="checkbox" id="spamoff_bot_protection_forms_<?php echo esc_attr( $key ); ?>" name="spamoff_bot_protection_forms[<?php echo esc_attr( $key ); ?>]" value="yes" <?php checked( isset( $forms[ $key ] ) && 'yes' === $forms[ $key ] ); ?>>
    220285                                </td>
    221286                            </tr>
    … …  
    223288                    </tbody>
    224289                </table>
     290                <p class="description"><?php esc_html_e( '"Checkout: guests only" skips customers who are logged in. It has no effect while "Checkout: all customers" is on.', 'spamoff-bot-protection' ); ?></p>
     291                <p class="description"><?php esc_html_e( 'WordPress Login also covers login forms made with wp_login_form(). XML-RPC and application-password logins have no form and are not covered.', 'spamoff-bot-protection' ); ?></p>
    225292            </td>
    226293        </tr>
    … …  
    237304        $v2_size      = isset( $customization['v2_size'] ) ? $customization['v2_size'] : 'normal';
    238305        $error_failed = isset( $customization['error_message_failed'] ) ? $customization['error_message_failed'] : __( 'Verification failed. Please try again.', 'spamoff-bot-protection' );
     306        $hide_badge   = isset( $customization['hide_v3_badge'] ) && 'yes' === $customization['hide_v3_badge'];
    239307        ?>
    240         <tr valign="top">
     308        <tr>
    241309            <th scope="row" class="titledesc"><?php esc_html_e( 'Customization Options', 'spamoff-bot-protection' ); ?></th>
    242310            <td class="forminp">
    … …  
    247315                            <td style="padding: 10px;">
    248316                                <code>[spamoff_bot_protection]</code>
    249                                 <p class="description"><?php esc_html_e( 'Use this shortcode to manually place the captcha anywhere.', 'spamoff-bot-protection' ); ?></p>
     317                                <p class="description"><?php esc_html_e( 'Places the captcha inside a custom form. The form\'s handler must verify it by calling spamoff_bot_protection_verify_submission().', 'spamoff-bot-protection' ); ?></p>
    250318                            </td>
    251319                        </tr>
    252320                        <tr>
    253                             <td style="padding: 10px;"><?php esc_html_e( 'reCAPTCHA v2 Theme', 'spamoff-bot-protection' ); ?></td>
     321                            <td style="padding: 10px;"><label for="spamoff_bot_protection_v2_theme"><?php esc_html_e( 'reCAPTCHA v2 Theme', 'spamoff-bot-protection' ); ?></label></td>
    254322                            <td style="padding: 10px;">
    255                                 <select name="spamoff_bot_protection_customization[v2_theme]">
     323                                <select id="spamoff_bot_protection_v2_theme" name="spamoff_bot_protection_customization[v2_theme]">
    256324                                    <option value="light" <?php selected( $v2_theme, 'light' ); ?>><?php esc_html_e( 'Light', 'spamoff-bot-protection' ); ?></option>
    257325                                    <option value="dark" <?php selected( $v2_theme, 'dark' ); ?>><?php esc_html_e( 'Dark', 'spamoff-bot-protection' ); ?></option>
    … …  
    260328                        </tr>
    261329                        <tr>
    262                             <td style="padding: 10px;"><?php esc_html_e( 'reCAPTCHA v2 Size', 'spamoff-bot-protection' ); ?></td>
     330                            <td style="padding: 10px;"><label for="spamoff_bot_protection_v2_size"><?php esc_html_e( 'reCAPTCHA v2 Size', 'spamoff-bot-protection' ); ?></label></td>
    263331                            <td style="padding: 10px;">
    264                                 <select name="spamoff_bot_protection_customization[v2_size]">
     332                                <select id="spamoff_bot_protection_v2_size" name="spamoff_bot_protection_customization[v2_size]">
    265333                                    <option value="normal" <?php selected( $v2_size, 'normal' ); ?>><?php esc_html_e( 'Normal', 'spamoff-bot-protection' ); ?></option>
    266334                                    <option value="compact" <?php selected( $v2_size, 'compact' ); ?>><?php esc_html_e( 'Compact', 'spamoff-bot-protection' ); ?></option>
    … …  
    269337                        </tr>
    270338                        <tr>
    271                             <td style="padding: 10px;"><?php esc_html_e( 'Failed Verification Message', 'spamoff-bot-protection' ); ?></td>
     339                            <td style="padding: 10px;"><label for="spamoff_bot_protection_hide_v3_badge"><?php esc_html_e( 'Hide reCAPTCHA v3 Badge', 'spamoff-bot-protection' ); ?></label></td>
    272340                            <td style="padding: 10px;">
    273                                 <input type="text" name="spamoff_bot_protection_customization[error_message_failed]" value="<?php echo esc_attr( $error_failed ); ?>" style="width: 100%;">
     341                                <input type="checkbox" id="spamoff_bot_protection_hide_v3_badge" name="spamoff_bot_protection_customization[hide_v3_badge]" value="yes" <?php checked( $hide_badge ); ?>>
     342                                <p class="description"><?php esc_html_e( 'Hides the floating badge and shows Google\'s required reCAPTCHA notice inside each protected form instead. The Checkout block keeps the badge.', 'spamoff-bot-protection' ); ?></p>
     343                            </td>
     344                        </tr>
     345                        <tr>
     346                            <td style="padding: 10px;"><label for="spamoff_bot_protection_error_message_failed"><?php esc_html_e( 'Failed Verification Message', 'spamoff-bot-protection' ); ?></label></td>
     347                            <td style="padding: 10px;">
     348                                <input type="text" id="spamoff_bot_protection_error_message_failed" name="spamoff_bot_protection_customization[error_message_failed]" value="<?php echo esc_attr( $error_failed ); ?>" style="width: 100%;">
    274349                            </td>
    275350                        </tr>
    … …  
    281356    }
    282357
     358    /**
     359     * WooCommerce checks the nonce and capability before saving; check again because
     360     * this reads $_POST directly.
     361     */
    283362    public function save_forms_settings() {
    284         if ( ! isset( $_POST['_wpnonce'] ) ) {
     363        if ( ! current_user_can( 'manage_woocommerce' ) || ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'woocommerce-settings' ) ) {
    285364            return;
    286365        }
    287         $nonce = sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) );
    288         if ( ! wp_verify_nonce( $nonce, 'woocommerce-settings' ) ) {
     366
     367        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only whitelisted keys with the value "yes" are kept below.
     368        $posted = isset( $_POST['spamoff_bot_protection_forms'] ) ? (array) wp_unslash( $_POST['spamoff_bot_protection_forms'] ) : array();
     369
     370        $forms = array();
     371        foreach ( Helper::FORMS as $form ) {
     372            if ( isset( $posted[ $form ] ) && 'yes' === $posted[ $form ] ) {
     373                $forms[ $form ] = 'yes';
     374            }
     375        }
     376
     377        update_option( 'spamoff_bot_protection_forms', $forms );
     378    }
     379
     380    public function save_customization_settings() {
     381        if ( ! current_user_can( 'manage_woocommerce' ) || ! isset( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'woocommerce-settings' ) ) {
    289382            return;
    290383        }
    291384
    292         if ( isset( $_POST['spamoff_bot_protection_forms'] ) ) {
    293             // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below.
    294             $forms_data = wp_unslash( $_POST['spamoff_bot_protection_forms'] );
    295             $forms      = array_map( 'sanitize_text_field', (array) $forms_data );
    296             update_option( 'spamoff_bot_protection_forms', $forms );
    297         } else {
    298             update_option( 'spamoff_bot_protection_forms', array() );
    299         }
    300     }
    301 
    302     public function save_customization_settings() {
    303         if ( ! isset( $_POST['_wpnonce'] ) ) {
     385        if ( ! isset( $_POST['spamoff_bot_protection_customization'] ) ) {
    304386            return;
    305387        }
    306         $nonce = sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) );
    307         if ( ! wp_verify_nonce( $nonce, 'woocommerce-settings' ) ) {
    308             return;
    309         }
    310 
    311         if ( isset( $_POST['spamoff_bot_protection_customization'] ) ) {
    312             // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below.
    313             $customization_data = wp_unslash( $_POST['spamoff_bot_protection_customization'] );
    314 
    315             $customization = array(
    316                 'v2_theme'             => sanitize_text_field( $customization_data['v2_theme'] ?? 'light' ),
    317                 'v2_size'              => sanitize_text_field( $customization_data['v2_size'] ?? 'normal' ),
    318                 'error_message_failed' => sanitize_text_field( $customization_data['error_message_failed'] ?? '' ),
    319             );
    320 
    321             update_option( 'spamoff_bot_protection_customization', $customization );
    322         }
     388
     389        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Each field is whitelisted or sanitized below.
     390        $posted = (array) wp_unslash( $_POST['spamoff_bot_protection_customization'] );
     391
     392        $customization = array(
     393            'v2_theme'             => isset( $posted['v2_theme'] ) && 'dark' === $posted['v2_theme'] ? 'dark' : 'light',
     394            'v2_size'              => isset( $posted['v2_size'] ) && 'compact' === $posted['v2_size'] ? 'compact' : 'normal',
     395            'hide_v3_badge'        => isset( $posted['hide_v3_badge'] ) && 'yes' === $posted['hide_v3_badge'] ? 'yes' : 'no',
     396            'error_message_failed' => isset( $posted['error_message_failed'] ) && is_string( $posted['error_message_failed'] ) ? sanitize_text_field( $posted['error_message_failed'] ) : '',
     397        );
     398
     399        update_option( 'spamoff_bot_protection_customization', $customization );
    323400    }
    324401}
  • spamoff-bot-protection/tags/1.0.0/languages/spamoff-bot-protection.pot

    r3603369 r3714007  
    11# Copyright (C) 2026 pluginos
    2 # This file is distributed under the GPLv2 or later license.
     2# This file is distributed under the GPLv2 or later.
    33msgid ""
    44msgstr ""
    5 "Project-Id-Version: SpamOff – Spam & Bot Protection for WordPress 1.0.0\n"
    6 "Report-Msgid-Bugs-To: \n"
     5"Project-Id-Version: SpamOff – Google reCaptcha for WooCommerce 1.1.0\n"
     6"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/spamoff-bot-protection\n"
    77"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
    88"Language-Team: LANGUAGE <LL@li.org>\n"
    … …  
    1010"Content-Type: text/plain; charset=UTF-8\n"
    1111"Content-Transfer-Encoding: 8bit\n"
    12 "POT-Creation-Date: 2026-05-21T00:00:00+00:00\n"
     12"POT-Creation-Date: 2026-09-25T03:58:02+02:00\n"
    1313"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
    14 "X-Generator: hand-authored template, regenerate with `wp i18n make-pot`\n"
     14"X-Generator: WP-CLI 2.12.0\n"
    1515"X-Domain: spamoff-bot-protection\n"
    1616
     17#. Plugin Name of the plugin
    1718#: spamoff-bot-protection.php
    18 msgid "SpamOff – Spam & Bot Protection for WordPress requires WooCommerce to be installed and active."
    19 msgstr ""
    20 
    21 #: includes/class-spamoff-bot-protection-admin.php
     19#: includes/class-spamoff-bot-protection-admin.php:30
     20msgid "SpamOff – Google reCaptcha for WooCommerce"
     21msgstr ""
     22
     23#. Description of the plugin
     24#: spamoff-bot-protection.php
     25msgid "Protects WooCommerce and WordPress forms from bots and spam using Google reCAPTCHA v2 and v3, with full WooCommerce Block Checkout support."
     26msgstr ""
     27
     28#. Author of the plugin
     29#: spamoff-bot-protection.php
     30msgid "pluginos"
     31msgstr ""
     32
     33#: includes/class-spamoff-bot-protection-admin.php:24
     34msgid "SpamOff adds Google reCAPTCHA to the forms you protect. On pages with a protected form, Google's reCAPTCHA script runs in the visitor's browser, and when the form is submitted your server sends the reCAPTCHA response and the visitor's IP address to Google to check it."
     35msgstr ""
     36
     37#: includes/class-spamoff-bot-protection-admin.php:25
     38msgid "Suggested text:"
     39msgstr ""
     40
     41#: includes/class-spamoff-bot-protection-admin.php:26
     42msgid "We use Google reCAPTCHA on our login, registration, password reset, checkout and comment forms to protect this site from spam and abuse. reCAPTCHA collects hardware and software information, such as device and application data, and sends it to Google for analysis. Your use of reCAPTCHA is subject to the Google Privacy Policy and Terms of Service."
     43msgstr ""
     44
     45#: includes/class-spamoff-bot-protection-admin.php:35
    2246msgid "Settings"
    2347msgstr ""
    2448
    25 #: includes/class-spamoff-bot-protection-frontend.php
     49#: includes/class-spamoff-bot-protection-frontend.php:281
    2650msgid "Comment Submission Error"
    2751msgstr ""
    2852
    29 #: includes/class-spamoff-bot-protection-helper.php
     53#: includes/class-spamoff-bot-protection-frontend.php:315
     54msgid "reCAPTCHA v3 token for the checkout."
     55msgstr ""
     56
     57#: includes/class-spamoff-bot-protection-frontend.php:321
     58msgid "reCAPTCHA v3 action the token was created for."
     59msgstr ""
     60
     61#. translators: 1: Google Privacy Policy URL, 2: Google Terms of Service URL.
     62#: includes/class-spamoff-bot-protection-frontend.php:547
     63#, php-format
     64msgid "This site is protected by reCAPTCHA and the Google <a href=\"%1$s\" target=\"_blank\" rel=\"noopener noreferrer\">Privacy Policy</a> and <a href=\"%2$s\" target=\"_blank\" rel=\"noopener noreferrer\">Terms of Service</a> apply."
     65msgstr ""
     66
     67#: includes/class-spamoff-bot-protection-helper.php:250
     68msgid "Please complete the verification."
     69msgstr ""
     70
     71#: includes/class-spamoff-bot-protection-helper.php:259
     72#: includes/class-spamoff-bot-protection-settings.php:305
    3073msgid "Verification failed. Please try again."
    3174msgstr ""
    3275
    33 #: includes/class-spamoff-bot-protection-helper.php
    34 msgid "Please complete the verification."
    35 msgstr ""
    36 
    37 #: includes/class-spamoff-bot-protection-settings.php
     76#: includes/class-spamoff-bot-protection-settings.php:24
    3877msgid "SpamOff"
    3978msgstr ""
    4079
    41 #: includes/class-spamoff-bot-protection-settings.php
     80#: includes/class-spamoff-bot-protection-settings.php:49
     81#: includes/class-spamoff-bot-protection-settings.php:136
    4282msgid "General Settings"
    4383msgstr ""
    4484
    45 #: includes/class-spamoff-bot-protection-settings.php
     85#: includes/class-spamoff-bot-protection-settings.php:50
    4686msgid "Advanced Settings"
    4787msgstr ""
    4888
    49 #: includes/class-spamoff-bot-protection-settings.php
     89#: includes/class-spamoff-bot-protection-settings.php:51
     90#: includes/class-spamoff-bot-protection-settings.php:230
    5091msgid "Customization"
    5192msgstr ""
    5293
    53 #: includes/class-spamoff-bot-protection-settings.php
     94#. translators: %s: reCAPTCHA version, e.g. v3.
     95#: includes/class-spamoff-bot-protection-settings.php:79
     96#, php-format
     97msgid "Protection is off: add the reCAPTCHA %s Site Key and Secret Key under General Settings."
     98msgstr ""
     99
     100#: includes/class-spamoff-bot-protection-settings.php:86
     101msgid "Your checkout page uses the Checkout block, which only supports reCAPTCHA v3. Orders placed there are rejected until you save v3 keys. Select v3 under General Settings to enter them; they stay saved if you switch back to v2."
     102msgstr ""
     103
     104#: includes/class-spamoff-bot-protection-settings.php:138
    54105msgid "Configure your basic reCAPTCHA settings and API keys."
    55106msgstr ""
    56107
    57 #: includes/class-spamoff-bot-protection-settings.php
     108#: includes/class-spamoff-bot-protection-settings.php:139
     109msgid "Protected forms send visitor data to Google reCAPTCHA; suggested privacy-policy text is under Settings → Privacy → Policy Guide."
     110msgstr ""
     111
     112#: includes/class-spamoff-bot-protection-settings.php:143
    58113msgid "reCAPTCHA Type"
    59114msgstr ""
    60115
    61 #: includes/class-spamoff-bot-protection-settings.php
    62 msgid "Choose between reCAPTCHA v3 (invisible) and v2 (checkbox)."
    63 msgstr ""
    64 
    65 #: includes/class-spamoff-bot-protection-settings.php
     116#: includes/class-spamoff-bot-protection-settings.php:144
     117msgid "Choose between reCAPTCHA v3 (invisible) and v2 (checkbox). Google issues separate keys for each; both sets stay saved when you switch."
     118msgstr ""
     119
     120#: includes/class-spamoff-bot-protection-settings.php:148
    66121msgid "reCAPTCHA v3 (Invisible)"
    67122msgstr ""
    68123
    69 #: includes/class-spamoff-bot-protection-settings.php
     124#: includes/class-spamoff-bot-protection-settings.php:149
    70125msgid "reCAPTCHA v2 (Checkbox)"
    71126msgstr ""
    72127
    73 #: includes/class-spamoff-bot-protection-settings.php
    74 msgid "Site Key"
    75 msgstr ""
    76 
    77 #: includes/class-spamoff-bot-protection-settings.php
    78 msgid "Enter your Google reCAPTCHA site key."
    79 msgstr ""
    80 
    81 #: includes/class-spamoff-bot-protection-settings.php
    82 msgid "Secret Key"
    83 msgstr ""
    84 
    85 #: includes/class-spamoff-bot-protection-settings.php
    86 msgid "Enter your Google reCAPTCHA secret key."
    87 msgstr ""
    88 
    89 #: includes/class-spamoff-bot-protection-settings.php
     128#: includes/class-spamoff-bot-protection-settings.php:154
     129msgid "v3 Site Key"
     130msgstr ""
     131
     132#: includes/class-spamoff-bot-protection-settings.php:155
     133msgid "Enter your Google reCAPTCHA v3 site key. The Checkout block always uses the v3 keys."
     134msgstr ""
     135
     136#: includes/class-spamoff-bot-protection-settings.php:162
     137msgid "v3 Secret Key"
     138msgstr ""
     139
     140#: includes/class-spamoff-bot-protection-settings.php:163
     141msgid "Enter your Google reCAPTCHA v3 secret key."
     142msgstr ""
     143
     144#: includes/class-spamoff-bot-protection-settings.php:171
    90145msgid "Minimum Score for reCAPTCHA v3"
    91146msgstr ""
    92147
    93 #: includes/class-spamoff-bot-protection-settings.php
     148#: includes/class-spamoff-bot-protection-settings.php:172
    94149msgid "Minimum score required (0.0 - 1.0). Submissions below this score will be rejected."
    95150msgstr ""
    96151
    97 #: includes/class-spamoff-bot-protection-settings.php
     152#: includes/class-spamoff-bot-protection-settings.php:185
     153msgid "v2 Site Key"
     154msgstr ""
     155
     156#: includes/class-spamoff-bot-protection-settings.php:186
     157msgid "Enter your Google reCAPTCHA v2 (\"I'm not a robot\" Checkbox) site key."
     158msgstr ""
     159
     160#: includes/class-spamoff-bot-protection-settings.php:193
     161msgid "v2 Secret Key"
     162msgstr ""
     163
     164#: includes/class-spamoff-bot-protection-settings.php:194
     165msgid "Enter your Google reCAPTCHA v2 secret key."
     166msgstr ""
     167
     168#: includes/class-spamoff-bot-protection-settings.php:211
    98169msgid "Form Protection"
    99170msgstr ""
    100171
    101 #: includes/class-spamoff-bot-protection-settings.php
     172#: includes/class-spamoff-bot-protection-settings.php:213
    102173msgid "Select which forms to protect."
    103174msgstr ""
    104175
    105 #: includes/class-spamoff-bot-protection-settings.php
     176#: includes/class-spamoff-bot-protection-settings.php:232
    106177msgid "Customize the appearance and messages."
    107178msgstr ""
    108179
    109 #: includes/class-spamoff-bot-protection-settings.php
    110 msgid "Customer Login"
    111 msgstr ""
    112 
    113 #: includes/class-spamoff-bot-protection-settings.php
    114 msgid "Customer Registration"
    115 msgstr ""
    116 
    117 #: includes/class-spamoff-bot-protection-settings.php
    118 msgid "Checkout Page"
    119 msgstr ""
    120 
    121 #: includes/class-spamoff-bot-protection-settings.php
    122 msgid "Guest Checkout"
    123 msgstr ""
    124 
    125 #: includes/class-spamoff-bot-protection-settings.php
    126 msgid "Lost Password"
    127 msgstr ""
    128 
    129 #: includes/class-spamoff-bot-protection-settings.php
     180#: includes/class-spamoff-bot-protection-settings.php:251
     181msgid "WordPress Login (wp-login.php)"
     182msgstr ""
     183
     184#: includes/class-spamoff-bot-protection-settings.php:252
     185msgid "WordPress Registration (wp-login.php)"
     186msgstr ""
     187
     188#: includes/class-spamoff-bot-protection-settings.php:253
     189msgid "WordPress Lost Password (wp-login.php)"
     190msgstr ""
     191
     192#: includes/class-spamoff-bot-protection-settings.php:254
    130193msgid "WordPress Comments"
    131194msgstr ""
    132195
    133 #: includes/class-spamoff-bot-protection-settings.php
     196#: includes/class-spamoff-bot-protection-settings.php:255
     197msgid "WooCommerce Login (My Account and checkout)"
     198msgstr ""
     199
     200#: includes/class-spamoff-bot-protection-settings.php:256
     201msgid "WooCommerce Registration (My Account)"
     202msgstr ""
     203
     204#: includes/class-spamoff-bot-protection-settings.php:257
     205msgid "WooCommerce Lost Password (My Account)"
     206msgstr ""
     207
     208#: includes/class-spamoff-bot-protection-settings.php:258
     209msgid "WooCommerce Checkout: all customers"
     210msgstr ""
     211
     212#: includes/class-spamoff-bot-protection-settings.php:259
     213msgid "WooCommerce Checkout: guests only"
     214msgstr ""
     215
     216#: includes/class-spamoff-bot-protection-settings.php:270
    134217msgid "Protected Forms"
    135218msgstr ""
    136219
    137 #: includes/class-spamoff-bot-protection-settings.php
     220#: includes/class-spamoff-bot-protection-settings.php:275
    138221msgid "Form"
    139222msgstr ""
    140223
    141 #: includes/class-spamoff-bot-protection-settings.php
     224#: includes/class-spamoff-bot-protection-settings.php:276
    142225msgid "Enable"
    143226msgstr ""
    144227
    145 #: includes/class-spamoff-bot-protection-settings.php
     228#: includes/class-spamoff-bot-protection-settings.php:290
     229msgid "\"Checkout: guests only\" skips customers who are logged in. It has no effect while \"Checkout: all customers\" is on."
     230msgstr ""
     231
     232#: includes/class-spamoff-bot-protection-settings.php:291
     233msgid "WordPress Login also covers login forms made with wp_login_form(). XML-RPC and application-password logins have no form and are not covered."
     234msgstr ""
     235
     236#: includes/class-spamoff-bot-protection-settings.php:309
    146237msgid "Customization Options"
    147238msgstr ""
    148239
    149 #: includes/class-spamoff-bot-protection-settings.php
     240#: includes/class-spamoff-bot-protection-settings.php:314
    150241msgid "Shortcode"
    151242msgstr ""
    152243
    153 #: includes/class-spamoff-bot-protection-settings.php
    154 msgid "Use this shortcode to manually place the captcha anywhere."
    155 msgstr ""
    156 
    157 #: includes/class-spamoff-bot-protection-settings.php
     244#: includes/class-spamoff-bot-protection-settings.php:317
     245msgid "Places the captcha inside a custom form. The form's handler must verify it by calling spamoff_bot_protection_verify_submission()."
     246msgstr ""
     247
     248#: includes/class-spamoff-bot-protection-settings.php:321
    158249msgid "reCAPTCHA v2 Theme"
    159250msgstr ""
    160251
    161 #: includes/class-spamoff-bot-protection-settings.php
     252#: includes/class-spamoff-bot-protection-settings.php:324
    162253msgid "Light"
    163254msgstr ""
    164255
    165 #: includes/class-spamoff-bot-protection-settings.php
     256#: includes/class-spamoff-bot-protection-settings.php:325
    166257msgid "Dark"
    167258msgstr ""
    168259
    169 #: includes/class-spamoff-bot-protection-settings.php
     260#: includes/class-spamoff-bot-protection-settings.php:330
    170261msgid "reCAPTCHA v2 Size"
    171262msgstr ""
    172263
    173 #: includes/class-spamoff-bot-protection-settings.php
     264#: includes/class-spamoff-bot-protection-settings.php:333
    174265msgid "Normal"
    175266msgstr ""
    176267
    177 #: includes/class-spamoff-bot-protection-settings.php
     268#: includes/class-spamoff-bot-protection-settings.php:334
    178269msgid "Compact"
    179270msgstr ""
    180271
    181 #: includes/class-spamoff-bot-protection-settings.php
     272#: includes/class-spamoff-bot-protection-settings.php:339
     273msgid "Hide reCAPTCHA v3 Badge"
     274msgstr ""
     275
     276#: includes/class-spamoff-bot-protection-settings.php:342
     277msgid "Hides the floating badge and shows Google's required reCAPTCHA notice inside each protected form instead. The Checkout block keeps the badge."
     278msgstr ""
     279
     280#: includes/class-spamoff-bot-protection-settings.php:346
    182281msgid "Failed Verification Message"
    183282msgstr ""
     283
     284#: spamoff-bot-protection.php:61
     285msgid "SpamOff – Google reCaptcha for WooCommerce requires WooCommerce to be installed and active."
     286msgstr ""
  • spamoff-bot-protection/tags/1.0.0/readme.txt

    r3621350 r3714007  
    130130== Changelog ==
    131131
     132= 1.0.2 =
     133* Fixed a functionality issue.
     134
    132135= 1.0.1 =
    133136* Renamed the plugin to SpamOff – Google reCAPTCHA for WooCommerce.
  • spamoff-bot-protection/tags/1.0.0/spamoff-bot-protection.php

    r3603369 r3714007  
    11<?php
    22/**
    3  * Plugin Name: SpamOff – Spam & Bot Protection
     3 * Plugin Name: SpamOff – Google reCaptcha for WooCommerce
    44 * Description: Protects WooCommerce and WordPress forms from bots and spam using Google reCAPTCHA v2 and v3, with full WooCommerce Block Checkout support.
     5 * Version: 1.1.0
     6 * Requires at least: 6.5
     7 * Requires PHP: 7.4
     8 * Requires Plugins: woocommerce
     9 * Author: pluginos
    510 * License: GPLv2 or later
    611 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
    7  * Version: 1.0.0
    8  * Author: pluginos
    912 * Text Domain: spamoff-bot-protection
    1013 * Domain Path: /languages
    11  * Requires at least: 5.8
    12  * Requires PHP: 7.4
    13  * WC requires at least: 6.0
    14  * WC tested up to: 10.5
     14 * WC requires at least: 8.0
     15 * WC tested up to: 11.1
    1516 */
    1617
    … …  
    2223define( 'SPAMOFF_BOT_PROTECTION_PLUGIN_PATH', plugin_dir_path( __FILE__ ) );
    2324define( 'SPAMOFF_BOT_PROTECTION_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
    24 define( 'SPAMOFF_BOT_PROTECTION_PLUGIN_VERSION', '1.0.0' );
     25define( 'SPAMOFF_BOT_PROTECTION_PLUGIN_VERSION', '1.1.0' );
    2526
    2627class SpamOff_Bot_Protection_Plugin {
    … …  
    5758    public function woocommerce_missing_notice() {
    5859        ?>
    59         <div class="error">
    60             <p><?php esc_html_e( 'SpamOff – Spam & Bot Protection for WordPress requires WooCommerce to be installed and active.', 'spamoff-bot-protection' ); ?></p>
     60        <div class="notice notice-error">
     61            <p><?php esc_html_e( 'SpamOff – Google reCaptcha for WooCommerce requires WooCommerce to be installed and active.', 'spamoff-bot-protection' ); ?></p>
    6162        </div>
    6263        <?php
    … …  
    7273
    7374    /**
    74      * Declare compatibility with WooCommerce HPOS.
     75     * Declare compatibility with WooCommerce HPOS and the Cart & Checkout Blocks.
    7576     */
    7677    public function declare_compatibility() {
    7778        if ( class_exists( '\Automattic\WooCommerce\Utilities\FeaturesUtil' ) ) {
    7879            \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
     80            \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', __FILE__, true );
    7981        }
     82    }
     83
     84    /**
     85     * 1.0.0 stored one key pair. Since 1.1.0 each reCAPTCHA version has its own, so move
     86     * the old pair into the slot for the version that was selected.
     87     */
     88    private function maybe_upgrade() {
     89        $db_version = get_option( 'spamoff_bot_protection_db_version', '1.0.0' );
     90        if ( version_compare( $db_version, '1.1.0', '>=' ) ) {
     91            return;
     92        }
     93
     94        $type = get_option( 'spamoff_bot_protection_type', 'v3' );
     95        $type = in_array( $type, array( 'v3', 'v2' ), true ) ? $type : 'v3';
     96
     97        foreach ( array( 'site_key', 'secret_key' ) as $key ) {
     98            $legacy = get_option( 'spamoff_bot_protection_' . $key, '' );
     99            if ( '' !== $legacy && '' === get_option( 'spamoff_bot_protection_' . $type . '_' . $key, '' ) ) {
     100                update_option( 'spamoff_bot_protection_' . $type . '_' . $key, $legacy );
     101            }
     102            delete_option( 'spamoff_bot_protection_' . $key );
     103        }
     104
     105        update_option( 'spamoff_bot_protection_db_version', '1.1.0' );
    80106    }
    81107
    … …  
    86112
    87113        $this->load_files();
     114        $this->maybe_upgrade();
    88115
    89116        new SpamOff_Bot_Protection_Captcha();
  • spamoff-bot-protection/tags/1.0.0/uninstall.php

    r3603369 r3714007  
    1111$spamoff_bot_protection_options = array(
    1212    'spamoff_bot_protection_type',
    13     'spamoff_bot_protection_site_key',
    14     'spamoff_bot_protection_secret_key',
     13    'spamoff_bot_protection_v3_site_key',
     14    'spamoff_bot_protection_v3_secret_key',
     15    'spamoff_bot_protection_v2_site_key',
     16    'spamoff_bot_protection_v2_secret_key',
    1517    'spamoff_bot_protection_minimum_score',
    1618    'spamoff_bot_protection_forms',
    1719    'spamoff_bot_protection_customization',
     20    'spamoff_bot_protection_db_version',
     21    // Single key pair used before 1.1.0.
     22    'spamoff_bot_protection_site_key',
     23    'spamoff_bot_protection_secret_key',
    1824);
    1925
Note: See TracChangeset for help on using the changeset viewer.