Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
43 changes: 36 additions & 7 deletions coderedcms/models/page_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -1373,6 +1374,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):
"""
Expand Down Expand Up @@ -1613,12 +1623,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.recaptcha_secret_key,
utils.get_ip(request),
)
# Score ranges from 0 (likely spam) to 1 (likely good).
return rr.score < ls.recaptcha_threshold
elif ls.spam_service == ls.SpamService.RECAPTCHA_V2:
rr = verify_response(
request.POST.get("g-recaptcha-response", ""),
ls.recaptcha_secret_key,
utils.get_ip(request),
)
return not rr.success
return False

def process_spam_request(self, form, request):
Expand Down Expand Up @@ -1664,7 +1693,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)
Expand Down
70 changes: 70 additions & 0 deletions coderedcms/models/wagtailsettings_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -44,6 +45,18 @@ class LayoutSettings(ClusterableModel, BaseSiteSetting):
class Meta:
verbose_name = _("CRX Settings")

class SpamService(models.TextChoices):
NONE = ("", _("None"))
HONEYPOT = ("honeypot", _("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,
Expand Down Expand Up @@ -132,6 +145,43 @@ class Meta:
external_new_tab = models.BooleanField(
default=False, verbose_name=_("Open all external links in new tab")
)
spam_service = models.CharField(
blank=True,
max_length=10,
choices=SpamService.choices,
default=SpamService.HONEYPOT,
verbose_name=_("Spam Protection"),
help_text=_(
"Choose a technique or 3rd party service to help block spam submissions."
),
)
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=_("reCAPTCHA Site Key (Public)"),
help_text=_(
"Create this key in the Google reCAPTCHA or Google Cloud dashboard."
),
)
recaptcha_secret_key = models.CharField(
blank=True,
max_length=255,
verbose_name=_("reCAPTCHA Secret Key (Private)"),
help_text=_(
"Create this key in the Google reCAPTCHA or Google Cloud dashboard."
),
)
google_maps_api_key = models.CharField(
blank=True,
max_length=255,
Expand Down Expand Up @@ -189,6 +239,15 @@ class Meta:
],
heading=_("General"),
),
MultiFieldPanel(
[
FieldPanel("spam_service"),
FieldPanel("recaptcha_threshold"),
FieldPanel("recaptcha_public_key"),
FieldPanel("recaptcha_secret_key"),
],
heading=_("Form Settings"),
),
MultiFieldPanel(
[
FieldPanel("google_maps_api_key"),
Expand Down Expand Up @@ -229,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(
Expand Down
53 changes: 53 additions & 0 deletions coderedcms/recaptcha.py
Original file line number Diff line number Diff line change
@@ -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,
)
50 changes: 50 additions & 0 deletions coderedcms/templates/coderedcms/includes/form_button.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{% load i18n %}
{% with settings.coderedcms.LayoutSettings as ls %}

{% if page.spam_protection and ls.spam_service == ls.SpamService.HONEYPOT %}
<div style="overflow:hidden;width:0;height:0;" aria-hidden="true">
<label for="cr-decoy-comments">{% trans 'Leave this blank if you are a human' %}</label>
<textarea rows="1" name="cr-decoy-comments" id="cr-decoy-comments"></textarea>
</div>
{% elif page.spam_protection and ls.spam_service == ls.SpamService.RECAPTCHA_V2 %}
<div class="mb-3">
<div class="g-recaptcha" data-sitekey="{{ ls.recaptcha_public_key }}"></div>
</div>
{% endif %}

{% if page.spam_protection and ls.spam_service == ls.SpamService.RECAPTCHA_V3 %}
<input type="hidden" name="g-recaptcha-response">
<button
type="button"
class="btn {{page.button_size}} {{page.button_style}} {{page.button_css_class}}"
onclick="recaptchaSubmit('{{ page.get_form_id }}')"
>
{{ button_text|default:page.button_text }}
</button>
<script>
function recaptchaSubmit(formId) {
var form = document.getElementById(formId);
if (form.reportValidity()) {
grecaptcha.ready(function() {
grecaptcha.execute(
'{{ ls.recaptcha_public_key }}',
{action: 'submit'}
).then(function(token) {
// Set value for every token input on the page.
document.getElementsByName("g-recaptcha-response").forEach(
function(el) {el.value = token}
);
var form = document.getElementById(formId);
form.submit();
});
});
}
}
</script>
{% else %}
<button type="submit" class="btn {{page.button_size}} {{page.button_style}} {{page.button_css_class}}">
{{ button_text|default:page.button_text }}
</button>
{% endif %}

{% endwith %}
5 changes: 0 additions & 5 deletions coderedcms/templates/coderedcms/includes/form_honeypot.html

This file was deleted.

10 changes: 10 additions & 0 deletions coderedcms/templates/coderedcms/pages/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,16 @@ <h2 class="text-center my-5">{% trans "Related" %}</h2>
{% block struct_seo_extra %}{% endblock %}
{% endblock %}

{% block spam_service_scripts %}
{% with settings.coderedcms.LayoutSettings as ls %}
{% if ls.spam_service == ls.SpamService.RECAPTCHA_V3 %}
<script src="https://www.google.com/recaptcha/api.js?render={{ ls.recaptcha_public_key }}"></script>
{% elif ls.spam_service == ls.SpamService.RECAPTCHA_V2 %}
<script src="https://www.google.com/recaptcha/api.js"></script>
{% endif %}
{% endwith %}
{% endblock %}

{% block body_tracking_scripts %}
{% if not disable_analytics %}
{% if settings.coderedcms.AnalyticsSettings.gtm_id %}
Expand Down
13 changes: 2 additions & 11 deletions coderedcms/templates/coderedcms/pages/form_page.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,13 @@
{{ block.super }}
{% if page.form_live %}
<div class="container my-5">
<form class="{{ page.form_css_class }}" id="{{ page.form_id }}" action="{% pageurl self %}" method="POST" {% if form|is_file_form %}enctype="multipart/form-data"{% endif %}>
<form class="{{ page.form_css_class }}" id="{{ page.get_form_id }}" action="{% pageurl self %}" method="POST" {% if form|is_file_form %}enctype="multipart/form-data"{% endif %}>
{% csrf_token %}
{% bootstrap_form form layout="horizontal" %}

{% block captcha %}
{% if page.spam_protection %}
{% include "coderedcms/includes/form_honeypot.html" %}
{% endif %}
{% endblock %}

<div class="row">
<div class="{{'horizontal_label_class'|bootstrap_settings}}"></div>
<div class="{{'horizontal_field_class'|bootstrap_settings}}">
<button type="submit" class="btn {{page.button_size}} {{page.button_style}} {{page.button_css_class}}">
{{ page.button_text }}
</button>
{% include "coderedcms/includes/form_button.html" %}
</div>
</div>
</form>
Expand Down
Loading