From a7809f1e73d80a0b3fa0bf3fcb9f8d263d0fd8d1 Mon Sep 17 00:00:00 2001 From: Vince Salvino Date: Tue, 11 Feb 2025 17:09:23 -0500 Subject: [PATCH 1/5] WIP --- coderedcms/models/page_models.py | 46 +++++++++++++--- coderedcms/models/wagtailsettings_models.py | 45 ++++++++++++++++ coderedcms/recaptcha.py | 53 +++++++++++++++++++ .../coderedcms/includes/form_button.html | 34 ++++++++++++ .../coderedcms/includes/form_honeypot.html | 5 -- .../templates/coderedcms/pages/base.html | 6 +++ .../templates/coderedcms/pages/form_page.html | 13 +---- .../coderedcms/pages/form_page.mini.html | 11 +--- .../coderedcms/pages/stream_form_page.html | 14 ++--- coderedcms/utils.py | 21 ++++++++ 10 files changed, 207 insertions(+), 41 deletions(-) create mode 100644 coderedcms/recaptcha.py create mode 100644 coderedcms/templates/coderedcms/includes/form_button.html delete mode 100755 coderedcms/templates/coderedcms/includes/form_honeypot.html diff --git a/coderedcms/models/page_models.py b/coderedcms/models/page_models.py index 435c20e6..72fe6e7f 100755 --- a/coderedcms/models/page_models.py +++ b/coderedcms/models/page_models.py @@ -92,6 +92,7 @@ from coderedcms.forms import CoderedSubmissionsListView from coderedcms.models.snippet_models import ClassifierTerm from coderedcms.models.wagtailsettings_models import LayoutSettings +from coderedcms.recaptcha import verify_response from coderedcms.settings import crx_settings from coderedcms.widgets import ClassifierSelectWidget @@ -1228,6 +1229,9 @@ class CoderedFormMixin(models.Model): class Meta: abstract = True + # See: https://developers.google.com/recaptcha/docs/v3 + RECAPTCHA_THRESHOLD: float = 0.5 + submissions_list_view_class = CoderedSubmissionsListView encoder = DjangoJSONEncoder @@ -1373,6 +1377,15 @@ class Meta: FieldPanel("spam_protection"), ] + @property + def get_form_id(self): + """ + Returns a suitable HTML element ID. + """ + if self.form_id: + return self.form_id.strip() + return f"crx-form-{self.pk}" + @property def form_live(self): """ @@ -1613,12 +1626,31 @@ def get_form(self, request, *args, **kwargs): return form_class(request.POST, request.FILES, *args, **form_params) return form_class(*args, **form_params) - def contains_spam(self, request): - """ - Checks to see if the spam honeypot was filled out. - """ - if request.POST.get("cr-decoy-comments", None): - return True + def contains_spam(self, request) -> bool: + """ + Checks if the site's spam_service identifies spam. + """ + if not self.spam_protection: + return False + ls = LayoutSettings.for_site(self.get_site()) + if ls.spam_service == ls.SpamService.HONEYPOT: + if request.POST.get("cr-decoy-comments", None): + return True + elif ls.spam_service == ls.SpamService.RECAPTCHA_V3: + rr = verify_response( + request.POST.get("g-recaptcha-response", ""), + ls.google_recaptcha_secret_key, + utils.get_ip(request), + ) + # Score ranges from 0 (likely spam) to 1 (likely good). + return rr.score < self.RECAPTCHA_THRESHOLD + elif ls.spam_service == ls.SpamService.RECAPTCHA_V2: + rr = verify_response( + request.POST.get("g-recaptcha-response", ""), + ls.google_recaptcha_secret_key, + utils.get_ip(request), + ) + return not rr.success return False def process_spam_request(self, form, request): @@ -1664,7 +1696,7 @@ def process_form_get(self, form, request): def serve(self, request, *args, **kwargs): form = self.get_form(request, page=self, user=request.user) if request.method == "POST": - if self.spam_protection and self.contains_spam(request): + if self.contains_spam(request): return self.process_spam_request(form, request) return self.process_form_post(form, request) return self.process_form_get(form, request) diff --git a/coderedcms/models/wagtailsettings_models.py b/coderedcms/models/wagtailsettings_models.py index 74a7b69d..7cd16d3b 100755 --- a/coderedcms/models/wagtailsettings_models.py +++ b/coderedcms/models/wagtailsettings_models.py @@ -44,6 +44,18 @@ class LayoutSettings(ClusterableModel, BaseSiteSetting): class Meta: verbose_name = _("CRX Settings") + class SpamService(models.TextChoices): + NONE = "", _("None") + HONEYPOT = "field", _("Basic - honeypot technique") + RECAPTCHA_V3 = ( + "recaptcha3", + _("reCAPTCHA v3 - Invisible (requires API key)"), + ) + RECAPTCHA_V2 = ( + "recaptcha2", + _("reCAPTCHA v2 - I am not a robot (requires API key)"), + ) + logo = models.ForeignKey( get_image_model_string(), null=True, @@ -132,6 +144,31 @@ class Meta: external_new_tab = models.BooleanField( default=False, verbose_name=_("Open all external links in new tab") ) + spam_service = models.CharField( + max_length=10, + choices=SpamService, + default=SpamService.HONEYPOT, + verbose_name=_("Spam Protection"), + help_text=_( + "Choose a technique or 3rd party service to help block spam submissions." + ), + ) + google_recaptcha_public_key = models.CharField( + blank=True, + max_length=255, + verbose_name=_("Google reCAPTCHA Site (Public) Key"), + help_text=_( + "Create this key in the Google reCAPTCHA or Google Cloud dashboard." + ), + ) + google_recaptcha_secret_key = models.CharField( + blank=True, + max_length=255, + verbose_name=_("Google reCAPTCHA Secret (Private) Key"), + help_text=_( + "Create this key in the Google reCAPTCHA or Google Cloud dashboard." + ), + ) google_maps_api_key = models.CharField( blank=True, max_length=255, @@ -189,6 +226,14 @@ class Meta: ], heading=_("General"), ), + MultiFieldPanel( + [ + FieldPanel("spam_service"), + FieldPanel("google_recaptcha_public_key"), + FieldPanel("google_recaptcha_secret_key"), + ], + heading=_("Form Settings"), + ), MultiFieldPanel( [ FieldPanel("google_maps_api_key"), diff --git a/coderedcms/recaptcha.py b/coderedcms/recaptcha.py new file mode 100644 index 00000000..7656eeef --- /dev/null +++ b/coderedcms/recaptcha.py @@ -0,0 +1,53 @@ +import json +import logging +import typing +from urllib.parse import urlencode +from urllib.request import Request +from urllib.request import urlopen + + +logger = logging.getLogger("coderedcms") + + +class RecaptchaResponse(typing.NamedTuple): + success: bool + score: float + error_codes: typing.List[str] + original_data: typing.Dict[str, typing.Any] + + +def verify_response(recaptcha_response: str, secret_key: str, remoteip: str): + """ + Verifies a response from reCAPTCHA front-end. + + * recaptcha_response = The token from the front-end, typically + ``g-recaptcha-response`` in POST parameters. + + * secret_key = reCAPTCHA secret/private API key. + + * remoteip = The form submitter's IP address. + """ + params = { + "secret": secret_key, + "response": recaptcha_response, + "remoteip": remoteip, + } + request = Request( + url="https://www.google.com/recaptcha/api/siteverify", + method="POST", + data=bytes(urlencode(params), encoding="utf8"), + headers={ + "Content-type": "application/x-www-form-urlencoded", + }, + ) + response = urlopen(request) + data = json.loads(response.read().decode("utf8")) + response.close() + logger.info(f"reCAPTCHA response: {data}") + # Default to good (likely not spam) values if they are not present. + return RecaptchaResponse( + success=data.get("success", True), + score=data.get("score", 1.0), + error_codes=data.get("error-codes", []), + original_data=data, + ) diff --git a/coderedcms/templates/coderedcms/includes/form_button.html b/coderedcms/templates/coderedcms/includes/form_button.html new file mode 100644 index 00000000..4d77f307 --- /dev/null +++ b/coderedcms/templates/coderedcms/includes/form_button.html @@ -0,0 +1,34 @@ +{% load i18n %} +{% with settings.coderedcms.LayoutSettings as ls %} +{% if ls.spam_service == ls.SpamService.HONEYPOT %} + +{% elif ls.spam_service == ls.SpamService.RECAPTCHA_V2 %} +
+
+
+{% endif %} + +{% if ls.spam_service == ls.SpamService.RECAPTCHA_V3 %} + + +{% else %} + +{% endif %} +{% endwith %} diff --git a/coderedcms/templates/coderedcms/includes/form_honeypot.html b/coderedcms/templates/coderedcms/includes/form_honeypot.html deleted file mode 100755 index 2a718c6a..00000000 --- a/coderedcms/templates/coderedcms/includes/form_honeypot.html +++ /dev/null @@ -1,5 +0,0 @@ -{% load i18n %} - diff --git a/coderedcms/templates/coderedcms/pages/base.html b/coderedcms/templates/coderedcms/pages/base.html index f3cf7cb3..725c86fd 100755 --- a/coderedcms/templates/coderedcms/pages/base.html +++ b/coderedcms/templates/coderedcms/pages/base.html @@ -187,6 +187,12 @@

{% trans "Related" %}

{% block struct_seo_extra %}{% endblock %} {% endblock %} + {% block spam_service_scripts %} + {% if settings.coderedcms.LayoutSettings.google_recaptcha_public_key %} + + {% endif %} + {% endblock %} + {% block body_tracking_scripts %} {% if not disable_analytics %} {% if settings.coderedcms.AnalyticsSettings.gtm_id %} diff --git a/coderedcms/templates/coderedcms/pages/form_page.html b/coderedcms/templates/coderedcms/pages/form_page.html index 61ea0dc8..4870587e 100755 --- a/coderedcms/templates/coderedcms/pages/form_page.html +++ b/coderedcms/templates/coderedcms/pages/form_page.html @@ -4,22 +4,13 @@ {{ block.super }} {% if page.form_live %}
-
+ {% csrf_token %} {% bootstrap_form form layout="horizontal" %} - - {% block captcha %} - {% if page.spam_protection %} - {% include "coderedcms/includes/form_honeypot.html" %} - {% endif %} - {% endblock %} -
- + {% include "coderedcms/includes/form_button.html" %}
diff --git a/coderedcms/templates/coderedcms/pages/form_page.mini.html b/coderedcms/templates/coderedcms/pages/form_page.mini.html index 5f21518f..dfd896b9 100644 --- a/coderedcms/templates/coderedcms/pages/form_page.mini.html +++ b/coderedcms/templates/coderedcms/pages/form_page.mini.html @@ -2,21 +2,14 @@ {% with page=self.page.specific %} {% if page.form_live %} {% get_pageform page request as form %} -
{% csrf_token %} {% bootstrap_form form layout="horizontal" %} - {% block captcha %} - {% if page.spam_protection %} - {% include "coderedcms/includes/form_honeypot.html" %} - {% endif %} - {% endblock %}
- + {% include "coderedcms/includes/form_button.html" %}
diff --git a/coderedcms/templates/coderedcms/pages/stream_form_page.html b/coderedcms/templates/coderedcms/pages/stream_form_page.html index c1a9b076..0596adbe 100755 --- a/coderedcms/templates/coderedcms/pages/stream_form_page.html +++ b/coderedcms/templates/coderedcms/pages/stream_form_page.html @@ -32,12 +32,6 @@ {% endfor %} {% endblock %} - {% block captcha %} - {% if page.spam_protection %} - {% include "coderedcms/includes/form_honeypot.html" %} - {% endif %} - {% endblock %} - {% block stream_form_actions %}
@@ -47,9 +41,11 @@ Previous {% endif %} - + {% if steps|last == step %} + {% include "coderedcms/includes/form_button.html" with button_text=page.button_text %} + {% else %} + {% include "coderedcms/includes/form_button.html" with button_text="Next" %} + {% endif %}
{% endblock %} diff --git a/coderedcms/utils.py b/coderedcms/utils.py index 1701da94..128e9950 100755 --- a/coderedcms/utils.py +++ b/coderedcms/utils.py @@ -1,5 +1,6 @@ from django.core.exceptions import ValidationError from django.core.validators import URLValidator +from django.http import HttpRequest from django.utils.html import mark_safe from coderedcms.settings import crx_settings @@ -45,3 +46,23 @@ def fix_ical_datetime_format(dt_str): dt_str = dt_str[:-3] + dt_str[-2:] return dt_str return dt_str + + +def get_ip(request: HttpRequest) -> str: + """ + Get the real IP address from a request. + """ + for header in [ + "X-Forwarded-For", + "X-Real-Ip", + "X-Client-Ip", + "Cf-Connecting-Ip", + "Remote-Addr", + ]: + ip = request.headers.get(header, "") + # Forwarded for headers can contain multiple IPs, the first being the + # real IP and subsequent being proxies along the way. + # We only need the first. + if ip: + return ip.split(",")[0] + return "" From 92300d1b27432004468cefc8c62e1f4eaaccf5ea Mon Sep 17 00:00:00 2001 From: Vince Salvino Date: Tue, 11 Feb 2025 18:44:34 -0500 Subject: [PATCH 2/5] Working --- ...tsettings_recaptcha_public_key_and_more.py | 63 +++++++++++++++++++ coderedcms/models/page_models.py | 9 +-- coderedcms/models/wagtailsettings_models.py | 43 ++++++++++--- .../coderedcms/includes/form_button.html | 28 ++++++--- .../templates/coderedcms/pages/base.html | 6 +- 5 files changed, 126 insertions(+), 23 deletions(-) create mode 100644 coderedcms/migrations/0044_layoutsettings_recaptcha_public_key_and_more.py diff --git a/coderedcms/migrations/0044_layoutsettings_recaptcha_public_key_and_more.py b/coderedcms/migrations/0044_layoutsettings_recaptcha_public_key_and_more.py new file mode 100644 index 00000000..e1339031 --- /dev/null +++ b/coderedcms/migrations/0044_layoutsettings_recaptcha_public_key_and_more.py @@ -0,0 +1,63 @@ +# Generated by Django 4.2.19 on 2025-02-11 23:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("coderedcms", "0043_remove_coderedpage_struct_org_actions_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="layoutsettings", + name="recaptcha_public_key", + field=models.CharField( + blank=True, + help_text="Create this key in the Google reCAPTCHA or Google Cloud dashboard.", + max_length=255, + verbose_name="reCAPTCHA Site Key (Public)", + ), + ), + migrations.AddField( + model_name="layoutsettings", + name="recaptcha_secret_key", + field=models.CharField( + blank=True, + help_text="Create this key in the Google reCAPTCHA or Google Cloud dashboard.", + max_length=255, + verbose_name="reCAPTCHA Secret Key (Private)", + ), + ), + migrations.AddField( + model_name="layoutsettings", + name="recaptcha_threshold", + field=models.DecimalField( + decimal_places=1, + default=0.5, + help_text="reCAPTCHA v3 returns a score (0.0 is very likely a bot, 1.0 is very likely a good interaction). Reject submissions below this score (recommended 0.5).", + max_digits=2, + verbose_name="reCAPTCHA Threshold", + ), + ), + migrations.AddField( + model_name="layoutsettings", + name="spam_service", + field=models.CharField( + blank=True, + choices=[ + ("", "None"), + ("honeypot", "Basic - honeypot technique"), + ("recaptcha3", "reCAPTCHA v3 - Invisible (requires API key)"), + ( + "recaptcha2", + "reCAPTCHA v2 - I am not a robot (requires API key)", + ), + ], + default="honeypot", + help_text="Choose a technique or 3rd party service to help block spam submissions.", + max_length=10, + verbose_name="Spam Protection", + ), + ), + ] diff --git a/coderedcms/models/page_models.py b/coderedcms/models/page_models.py index 72fe6e7f..31e83dd3 100755 --- a/coderedcms/models/page_models.py +++ b/coderedcms/models/page_models.py @@ -1229,9 +1229,6 @@ class CoderedFormMixin(models.Model): class Meta: abstract = True - # See: https://developers.google.com/recaptcha/docs/v3 - RECAPTCHA_THRESHOLD: float = 0.5 - submissions_list_view_class = CoderedSubmissionsListView encoder = DjangoJSONEncoder @@ -1639,15 +1636,15 @@ def contains_spam(self, request) -> bool: elif ls.spam_service == ls.SpamService.RECAPTCHA_V3: rr = verify_response( request.POST.get("g-recaptcha-response", ""), - ls.google_recaptcha_secret_key, + ls.recaptcha_secret_key, utils.get_ip(request), ) # Score ranges from 0 (likely spam) to 1 (likely good). - return rr.score < self.RECAPTCHA_THRESHOLD + return rr.score < ls.recaptcha_threshold elif ls.spam_service == ls.SpamService.RECAPTCHA_V2: rr = verify_response( request.POST.get("g-recaptcha-response", ""), - ls.google_recaptcha_secret_key, + ls.recaptcha_secret_key, utils.get_ip(request), ) return not rr.success diff --git a/coderedcms/models/wagtailsettings_models.py b/coderedcms/models/wagtailsettings_models.py index 7cd16d3b..d45d181d 100755 --- a/coderedcms/models/wagtailsettings_models.py +++ b/coderedcms/models/wagtailsettings_models.py @@ -4,6 +4,7 @@ Global project or developer settings should be defined in coderedcms.settings.py . """ +from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ from modelcluster.fields import ParentalKey @@ -45,8 +46,8 @@ class Meta: verbose_name = _("CRX Settings") class SpamService(models.TextChoices): - NONE = "", _("None") - HONEYPOT = "field", _("Basic - honeypot technique") + NONE = ("", _("None")) + HONEYPOT = ("honeypot", _("Basic - honeypot technique")) RECAPTCHA_V3 = ( "recaptcha3", _("reCAPTCHA v3 - Invisible (requires API key)"), @@ -145,26 +146,38 @@ class SpamService(models.TextChoices): default=False, verbose_name=_("Open all external links in new tab") ) spam_service = models.CharField( + blank=True, max_length=10, - choices=SpamService, + choices=SpamService.choices, default=SpamService.HONEYPOT, verbose_name=_("Spam Protection"), help_text=_( "Choose a technique or 3rd party service to help block spam submissions." ), ) - google_recaptcha_public_key = models.CharField( + recaptcha_threshold = models.DecimalField( + default=0.5, + max_digits=2, + decimal_places=1, + verbose_name=_("reCAPTCHA Threshold"), + help_text=_( + "reCAPTCHA v3 returns a score (0.0 is very likely a bot, " + "1.0 is very likely a good interaction). " + "Reject submissions below this score (recommended 0.5)." + ), + ) + recaptcha_public_key = models.CharField( blank=True, max_length=255, - verbose_name=_("Google reCAPTCHA Site (Public) Key"), + verbose_name=_("reCAPTCHA Site Key (Public)"), help_text=_( "Create this key in the Google reCAPTCHA or Google Cloud dashboard." ), ) - google_recaptcha_secret_key = models.CharField( + recaptcha_secret_key = models.CharField( blank=True, max_length=255, - verbose_name=_("Google reCAPTCHA Secret (Private) Key"), + verbose_name=_("reCAPTCHA Secret Key (Private)"), help_text=_( "Create this key in the Google reCAPTCHA or Google Cloud dashboard." ), @@ -229,8 +242,9 @@ class SpamService(models.TextChoices): MultiFieldPanel( [ FieldPanel("spam_service"), - FieldPanel("google_recaptcha_public_key"), - FieldPanel("google_recaptcha_secret_key"), + FieldPanel("recaptcha_threshold"), + FieldPanel("recaptcha_public_key"), + FieldPanel("recaptcha_secret_key"), ], heading=_("Form Settings"), ), @@ -274,6 +288,17 @@ def __init__(self, *args, **kwargs): ) self.navbar_format = crx_settings.CRX_FRONTEND_NAVBAR_FORMAT_DEFAULT + def clean(self): + """ + Make sure reCAPTCHA keys are set if selected. + """ + if self.spam_service in [ + self.SpamService.RECAPTCHA_V3, + self.SpamService.RECAPTCHA_V2, + ] and not (self.recaptcha_public_key and self.recaptcha_secret_key): + raise ValidationError(_("API keys are required to use reCAPTCHA.")) + return super().clean() + class NavbarOrderable(Orderable, models.Model): navbar_chooser = ParentalKey( diff --git a/coderedcms/templates/coderedcms/includes/form_button.html b/coderedcms/templates/coderedcms/includes/form_button.html index 4d77f307..22872a93 100644 --- a/coderedcms/templates/coderedcms/includes/form_button.html +++ b/coderedcms/templates/coderedcms/includes/form_button.html @@ -7,23 +7,37 @@ {% elif ls.spam_service == ls.SpamService.RECAPTCHA_V2 %}
-
+
{% endif %} {% if ls.spam_service == ls.SpamService.RECAPTCHA_V3 %} + {% else %} diff --git a/coderedcms/templates/coderedcms/pages/base.html b/coderedcms/templates/coderedcms/pages/base.html index 725c86fd..71529359 100755 --- a/coderedcms/templates/coderedcms/pages/base.html +++ b/coderedcms/templates/coderedcms/pages/base.html @@ -188,9 +188,13 @@

{% trans "Related" %}

{% endblock %} {% block spam_service_scripts %} - {% if settings.coderedcms.LayoutSettings.google_recaptcha_public_key %} + {% with settings.coderedcms.LayoutSettings as ls %} + {% if ls.spam_service == ls.SpamService.RECAPTCHA_V3 %} + + {% elif ls.spam_service == ls.SpamService.RECAPTCHA_V2 %} {% endif %} + {% endwith %} {% endblock %} {% block body_tracking_scripts %} From 516150a1e2f24e8cfa6f29254b269e0bbec3f1af Mon Sep 17 00:00:00 2001 From: Vince Salvino Date: Tue, 11 Feb 2025 18:49:30 -0500 Subject: [PATCH 3/5] Fix base.html --- coderedcms/templates/coderedcms/pages/base.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderedcms/templates/coderedcms/pages/base.html b/coderedcms/templates/coderedcms/pages/base.html index 71529359..733a7c08 100755 --- a/coderedcms/templates/coderedcms/pages/base.html +++ b/coderedcms/templates/coderedcms/pages/base.html @@ -190,7 +190,7 @@

{% trans "Related" %}

{% block spam_service_scripts %} {% with settings.coderedcms.LayoutSettings as ls %} {% if ls.spam_service == ls.SpamService.RECAPTCHA_V3 %} - + {% elif ls.spam_service == ls.SpamService.RECAPTCHA_V2 %} {% endif %} From 75ae5e902681dd2882e14625cce32af058d09864 Mon Sep 17 00:00:00 2001 From: Vince Salvino Date: Tue, 11 Feb 2025 19:22:11 -0500 Subject: [PATCH 4/5] Add docs --- docs/features/index.rst | 7 ++-- docs/features/spam-protection.rst | 56 +++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 docs/features/spam-protection.rst diff --git a/docs/features/index.rst b/docs/features/index.rst index ccfe8eb6..803791bd 100755 --- a/docs/features/index.rst +++ b/docs/features/index.rst @@ -6,9 +6,10 @@ Features contentblocks/index layoutblocks/index - import_export - mailchimp page_types/index + snippets/index related_pages searching - snippets/index + spam-protection + import_export + mailchimp diff --git a/docs/features/spam-protection.rst b/docs/features/spam-protection.rst new file mode 100644 index 00000000..111c0d8a --- /dev/null +++ b/docs/features/spam-protection.rst @@ -0,0 +1,56 @@ +Spam Protection +=============== + +CRX provides features to help block spam form submissions from your site. + +These can be toggled in **CRX Settings > Forms** + + +Basic (honeypot) +---------------- + +The default spam protection technique is a simple honeypot. This adds a hidden field to all forms, which some spammers might mistake for a real field. If the hidden field is filled out, the submission is rejected and a generic error message is shown to the user. + +While this technique is not the most effective, it can help stop some brute force spammers, and requires no additional setup or 3rd party services. + + +reCAPTCHA v3 (invisible) +------------------------ + +Google's reCAPTCHA v3 is invisible, meaning the visitor does not see anything and does not have to solve any challenges. This works by generating a score of how likely the submission is to be spam. + +By default, CRX will show a generic error message for scores lower than 0.5. This can be adjusted in **CRX Settings > Forms**. If your visitors are complaining that they are getting errors when submitting forms, you may want to lower this number. If you are still receiving a lot of spam submissions, you may want to raise it. + +**reCAPTCHA v3 requires API keys from Google.** When creating the API keys, you must select recAPTCHA v3, then enter those keys into **CRX Settings > Forms**. + +`Create reCAPTCHA API keys `_ + + +reCAPTCHA v2 ("I am not a robot") +--------------------------------- + +Google's reCAPTCHA v2 shows the famous "I am not a robot" checkbox on the form. This requires the visitor to click the box. In some cases, Google might require the visitor to solve a challenge, such as selecting images or solving a puzzle. + +**reCAPTCHA v2 requires API keys from Google.** When creating the API keys, you must select recAPTCHA v2 "I am not a robot", then enter those keys into **CRX Settings > Forms**. + +`Create reCAPTCHA API keys `_ + + +Customizing the spam error message +---------------------------------- + +The spam error message can be customized on a per-page basis by overriding the ``get_spam_message`` function as so: + +.. code-block:: python + + class FormPage(CoderedFormPage): + ... + + def get_spam_message(self) -> str: + return "Error submitting form. Please try again." + +| + +.. versionadded:: 5.0 + + reCAPTCHA v2 and v3 support was added in CRX 5.0. From a3f542cadcb24370ef0bbcd2c7b9f9948de8ac04 Mon Sep 17 00:00:00 2001 From: Vince Salvino Date: Tue, 11 Feb 2025 19:30:43 -0500 Subject: [PATCH 5/5] respect spam_protection setting --- coderedcms/templates/coderedcms/includes/form_button.html | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/coderedcms/templates/coderedcms/includes/form_button.html b/coderedcms/templates/coderedcms/includes/form_button.html index 22872a93..8c2b0e62 100644 --- a/coderedcms/templates/coderedcms/includes/form_button.html +++ b/coderedcms/templates/coderedcms/includes/form_button.html @@ -1,17 +1,18 @@ {% load i18n %} {% with settings.coderedcms.LayoutSettings as ls %} -{% if ls.spam_service == ls.SpamService.HONEYPOT %} + +{% if page.spam_protection and ls.spam_service == ls.SpamService.HONEYPOT %} -{% elif ls.spam_service == ls.SpamService.RECAPTCHA_V2 %} +{% elif page.spam_protection and ls.spam_service == ls.SpamService.RECAPTCHA_V2 %}
{% endif %} -{% if ls.spam_service == ls.SpamService.RECAPTCHA_V3 %} +{% if page.spam_protection and ls.spam_service == ls.SpamService.RECAPTCHA_V3 %}