Browse Source

Form Spam Protection (#210)

Adds an option to enable a honeypot on forms (on by default)
Cory Sutyak 5 năm trước cách đây
mục cha
commit
9008ea0d29

+ 86 - 48
coderedcms/models/page_models.py

@@ -8,6 +8,7 @@ import os
 import geocoder
 from django import forms
 from django.conf import settings
+from django.contrib import messages
 from django.core.files.uploadedfile import InMemoryUploadedFile, TemporaryUploadedFile
 from django.core.files.storage import FileSystemStorage
 from django.core.mail import EmailMessage
@@ -48,7 +49,7 @@ from wagtail.core.utils import resolve_model_string
 from wagtail.contrib.forms.edit_handlers import FormSubmissionsPanel
 from wagtail.contrib.forms.forms import WagtailAdminFormPageForm
 from wagtail.images.edit_handlers import ImageChooserPanel
-from wagtail.contrib.forms.models import AbstractFormSubmission, FormSubmission
+from wagtail.contrib.forms.models import FormSubmission
 from wagtail.search import index
 from wagtailcache.cache import WagtailCacheMixin
 
@@ -59,8 +60,7 @@ from coderedcms.blocks import (
     STREAMFORM_BLOCKS,
     ContentWallBlock,
     OpenHoursBlock,
-    StructuredDataActionBlock,
-    CoderedStreamFormStepBlock)
+    StructuredDataActionBlock)
 from coderedcms.fields import ColorField
 from coderedcms.forms import CoderedFormBuilder, CoderedSubmissionsListView
 from coderedcms.models.snippet_models import ClassifierTerm
@@ -1004,7 +1004,7 @@ class CoderedEventOccurrence(Orderable, BaseOccurrence):
 class CoderedFormMixin(models.Model):
 
     class Meta:
-        abstract=True
+        abstract = True
 
     submissions_list_view_class = CoderedSubmissionsListView
     encoder = DjangoJSONEncoder
@@ -1090,7 +1090,11 @@ class CoderedFormMixin(models.Model):
         verbose_name=_('Form expiry date/time'),
         help_text=_('Date and time when the FORM will no longer be available on the page.'),
     )
-
+    spam_protection = models.BooleanField(
+        default=True,
+        verbose_name=_('Spam Protection'),
+        help_text=_('When enabled, the CMS will filter out spam form submissions for this page.')
+    )
 
     body_content_panels = [
         MultiFieldPanel(
@@ -1128,7 +1132,8 @@ class CoderedFormMixin(models.Model):
                 ),
             ],
             _('Form Scheduled Publishing'),
-        )
+        ),
+        FieldPanel('spam_protection')
     ]
 
 
@@ -1269,7 +1274,7 @@ class CoderedFormMixin(models.Model):
         message = EmailMessage(**message_args)
         message.send()
 
-    def render_landing_page(self, request, form_submission=None, *args, **kwargs):
+    def render_landing_page(self, request, form_submission=None):
 
         """
         Renders the landing page.
@@ -1327,6 +1332,69 @@ class CoderedFormMixin(models.Model):
         view = self.submissions_list_view_class.as_view()
         return view(request, form_page=self, *args, **kwargs)
 
+    def get_form(self, request, *args, **kwargs):
+        form_class = self.get_form_class()
+        form_params = self.get_form_parameters()
+        form_params.update(kwargs)
+
+        if request.method == 'POST':
+            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
+        return False
+
+    def process_spam_request(self, form, request):
+        """
+        Called when spam is found in the request.
+        """
+        messages.error(request, self.get_spam_message())
+        logger.info("Detected spam submission on page: {0}\n{1}".format(self.title, vars(request)))
+
+        return self.process_form_get(form, request)
+
+    def get_spam_message(self):
+        return _("There was an error while processing your submission.  Please try again.")
+
+    def process_form_post(self, form, request):
+        if form.is_valid():
+            processed_data = self.process_data(form, request)
+            form_submission = self.get_submission_class()(
+                form_data=json.dumps(processed_data, cls=self.encoder),
+                page=self,
+            )
+            self.process_form_submission(
+                request=request,
+                form=form,
+                form_submission=form_submission,
+                processed_data=processed_data)
+            return self.render_landing_page(request, form_submission)
+        return self.process_form_get(form, request)
+
+    def process_form_get(self, form, request):
+        context = self.get_context(request)
+        context['form'] = form
+        response = render(
+            request,
+            self.get_template(request),
+            context
+        )
+        return response
+
+    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):
+                return self.process_spam_request(form, request)
+            return self.process_form_post(form, request)
+        return self.process_form_get(form, request)
+
+
 class CoderedFormPage(CoderedFormMixin, CoderedWebPage):
     """
     This is basically a clone of wagtail.contrib.forms.models.AbstractForm
@@ -1390,13 +1458,6 @@ class CoderedFormPage(CoderedFormMixin, CoderedWebPage):
     def get_form_parameters(self):
         return {}
 
-    def get_form(self, *args, **kwargs):
-        form_class = self.get_form_class()
-        form_params = self.get_form_parameters()
-        form_params.update(kwargs)
-
-        return form_class(*args, **form_params)
-
     def get_submission_class(self):
         """
         Returns submission class.
@@ -1407,33 +1468,6 @@ class CoderedFormPage(CoderedFormMixin, CoderedWebPage):
 
         return FormSubmission
 
-    def serve(self, request, *args, **kwargs):
-        if request.method == 'POST':
-            form = self.get_form(request.POST, request.FILES, page=self, user=request.user)
-
-            if form.is_valid():
-                processed_data = self.process_data(form, request)
-                form_submission = self.get_submission_class()(
-                    form_data=json.dumps(processed_data, cls=self.encoder),
-                    page=self,
-                )
-                self.process_form_submission(
-                    request=request,
-                    form=form,
-                    form_submission=form_submission,
-                    processed_data=processed_data)
-                return self.render_landing_page(request, form_submission, *args, **kwargs)
-        else:
-            form = self.get_form(page=self, user=request.user)
-
-        context = self.get_context(request)
-        context['form'] = form
-        response = render(
-            request,
-            self.get_template(request),
-            context
-        )
-        return response
 
 class CoderedSubmissionRevision(SubmissionRevision, models.Model):
     pass
@@ -1577,7 +1611,7 @@ class CoderedStreamFormMixin(StreamFormMixin):
         return user_submission
 
 
-class CoderedStreamFormPage(CoderedStreamFormMixin, CoderedFormMixin, CoderedWebPage):
+class CoderedStreamFormPage(CoderedFormMixin, CoderedStreamFormMixin, CoderedWebPage):
     class Meta:
         verbose_name = _('CodeRed Advanced Form Page')
         abstract = True
@@ -1595,10 +1629,8 @@ class CoderedStreamFormPage(CoderedStreamFormMixin, CoderedFormMixin, CoderedWeb
         InlinePanel('confirmation_emails', label=_('Confirmation Emails'))
     ]
 
-    def serve(self, request, *args, **kwargs):
-        context = self.get_context(request)
-        form = context['form']
-        if request.method == 'POST' and form.is_valid():
+    def process_form_post(self, form, request):
+        if form.is_valid():
             is_complete = self.steps.update_data()
             if is_complete:
                 submission = self.get_submission(request)
@@ -1609,9 +1641,15 @@ class CoderedStreamFormPage(CoderedStreamFormMixin, CoderedFormMixin, CoderedWeb
                     processed_data=submission.get_data()
                 )
                 normal_submission = submission.create_normal_submission()
-                return self.render_landing_page(request, normal_submission, *args, **kwargs)
+                return self.render_landing_page(request, normal_submission)
             return HttpResponseRedirect(self.url)
-        return CoderedWebPage.serve(self, request, *args, **kwargs)
+        return self.process_form_get(form, request)
+
+    def process_form_get(self, form, request):
+        return CoderedWebPage.serve(self, request)
+
+    def get_form(self, request, *args, **kwargs):
+        return self.get_context(request)['form']
 
     def get_storage(self):
         return FileSystemStorage(

+ 29 - 19
coderedcms/models/tests/test_page_models.py

@@ -1,7 +1,5 @@
-from django.contrib.auth.models import AnonymousUser
+from django.test import Client
 from wagtail.tests.utils import WagtailPageTests
-from django.test.client import RequestFactory
-from wagtail.core.models import Site
 
 from coderedcms.models.page_models import (
     CoderedArticleIndexPage,
@@ -33,10 +31,10 @@ class BasicPageTestCase():
     This is a testing mixin used to run common tests for basic versions of page types.
     """
     class Meta:
-        abstract=True
+        abstract = True
 
     def setUp(self):
-        self.request_factory = RequestFactory()
+        self.client = Client()
         self.basic_page = self.model(
             title=str(self.model._meta.verbose_name)
         )
@@ -47,19 +45,17 @@ class BasicPageTestCase():
         """
         Tests to make sure a basic version of the page serves a 200 from a GET request.
         """
-        request = self.request_factory.get(self.basic_page.url)
-        request.session = self.client.session
-        request.user = AnonymousUser()
-        request.site = Site.objects.all()[0]
-        response = self.basic_page.serve(request)
+
+        response = self.client.get(self.basic_page.url, follow=True)
         self.assertEqual(response.status_code, 200)
 
+
 class AbstractPageTestCase():
     """
     This is a testing mixin used to run common tests for abstract page types.
     """
     class Meta:
-        abstract=True
+        abstract = True
 
     def test_not_available(self):
         """
@@ -74,7 +70,7 @@ class ConcretePageTestCase():
     This is a testing mixin used to run common tests for concrete page types.
     """
     class Meta:
-        abstract=True
+        abstract = True
 
     def test_is_available(self):
         """
@@ -86,23 +82,37 @@ class ConcretePageTestCase():
 
 class ConcreteBasicPageTestCase(ConcretePageTestCase, BasicPageTestCase):
     class Meta:
-        abstract=True
+        abstract = True
+
 
 class ConcreteFormPageTestCase(ConcreteBasicPageTestCase):
     class Meta:
-        abstract=True
+        abstract = True
 
     def test_post(self):
         """
         Tests to make sure a basic version of the page serves a 200 from a POST request.
         """
-        request = self.request_factory.post(self.basic_page.url)
-        request.session = self.client.session
-        request.user = AnonymousUser()
-        request.site = Site.objects.all()[0]
-        response = self.basic_page.serve(request)
+        response = self.client.post(self.basic_page.url, follow=True)
         self.assertEqual(response.status_code, 200)
 
+    def test_spam(self):
+        """
+        Test to check if the default spam catching works.
+        """
+        response = self.client.post(self.basic_page.url, {'cr-decoy-comments': 'This is Spam'}, follow=True)
+        messages = list(response.context['messages'])
+        self.assertEqual(len(messages), 1)
+        self.assertEqual(str(messages[0]), self.basic_page.get_spam_message())
+
+    def test_not_spam(self):
+        """
+        Test to check if the default spam catching won't mark correct posts as spam.
+        """
+        response = self.client.post(self.basic_page.url)
+        self.assertFalse(hasattr(response, 'is_spam'))
+
+
 class CoderedArticleIndexPageTestCase(AbstractPageTestCase, WagtailPageTests):
     model = CoderedArticleIndexPage
 

+ 2 - 2
coderedcms/project_template/project_name/settings/base.py

@@ -78,8 +78,8 @@ MIDDLEWARE = [
     'django.middleware.clickjacking.XFrameOptionsMiddleware',
     'django.middleware.security.SecurityMiddleware',
 
-    # Error reporting. Uncomment this to recieve emails when a 404 is triggered.
-    #'django.middleware.common.BrokenLinkEmailsMiddleware',
+    #  Error reporting. Uncomment this to recieve emails when a 404 is triggered.
+    # 'django.middleware.common.BrokenLinkEmailsMiddleware',
 
     # CMS functionality
     'wagtail.core.middleware.SiteMiddleware',

+ 12 - 12
coderedcms/project_template/project_name/settings/prod.py

@@ -29,17 +29,17 @@ MANAGERS = ADMINS
 # Email address used to send error messages to ADMINS.
 SERVER_EMAIL = DEFAULT_FROM_EMAIL
 
-#DATABASES = {
-#    'default': {
-#        'ENGINE': 'django.db.backends.mysql',
-#        'HOST': 'localhost',
-#        'NAME': '{{ project_name }}',
-#        'USER': '{{ project_name }}',
-#        'PASSWORD': '',
-#        # If using SSL to connect to a cloud mysql database, spedify the CA as so.
-#        'OPTIONS': { 'ssl': { 'ca': '/path/to/certificate-authority.pem' } },
-#    }
-#}
+# DATABASES = {
+#     'default': {
+#         'ENGINE': 'django.db.backends.mysql',
+#         'HOST': 'localhost',
+#         'NAME': '{{ project_name }}',
+#         'USER': '{{ project_name }}',
+#         'PASSWORD': '',
+#         # If using SSL to connect to a cloud mysql database, spedify the CA as so.
+#         'OPTIONS': { 'ssl': { 'ca': '/path/to/certificate-authority.pem' } },
+#     }
+# }
 
 # Use template caching to speed up wagtail admin and front-end.
 # Requires reloading web server to pick up template changes.
@@ -69,7 +69,7 @@ CACHES = {
         'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache',
         'LOCATION': os.path.join(BASE_DIR, 'cache'),
         'KEY_PREFIX': 'coderedcms',
-        'TIMEOUT': 14400, # in seconds
+        'TIMEOUT': 14400,  # in seconds
     }
 }
 

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 2 - 7
coderedcms/project_template/website/migrations/0001_initial.py


+ 2 - 1
coderedcms/project_template/website/models.py

@@ -18,7 +18,7 @@ class ArticlePage(CoderedArticlePage):
     """
     class Meta:
         verbose_name = 'Article'
-        ordering = ['-first_published_at',]
+        ordering = ['-first_published_at', ]
 
     # Only allow this page to be created beneath an ArticleIndexPage.
     parent_page_types = ['website.ArticleIndexPage']
@@ -63,6 +63,7 @@ class FormPageField(CoderedFormField):
 
     page = ParentalKey('FormPage', related_name='form_fields')
 
+
 class FormConfirmEmail(CoderedEmail):
     """
     Sends a confirmation email after submitting a FormPage.

+ 5 - 0
coderedcms/templates/coderedcms/includes/form_honeypot.html

@@ -0,0 +1,5 @@
+{% load i18n %}
+<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>

+ 9 - 1
coderedcms/templates/coderedcms/pages/base.html

@@ -124,7 +124,15 @@
 
         <div id="content">
             {% block content %}
-
+                {% block messages %}
+                    {% if messages %}
+                        <div class="container">
+                        {% for message in messages %}
+                            <div class="alert alert-{{ message.tags|map_to_bootstrap_alert }}" role="alert">{{ message }}</div>
+                        {% endfor %}
+                        </div>
+                    {% endif %}
+                {% endblock %}
                 {% block content_pre_body %}{% endblock %}
 
                 {% block content_body %}

+ 8 - 0
coderedcms/templates/coderedcms/pages/form_page.html

@@ -10,7 +10,15 @@
 <div class="container">
     <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 %}>
         {% csrf_token %}
+
         {% bootstrap_form form layout='horizontal' %}
+
+        {% block captcha %}
+            {% if page.spam_protection %}
+                {% include 'coderedcms/includes/form_honeypot.html' %}
+            {% endif %}
+        {% endblock %}
+
         <div class="form-group mt-5 row">
             <div class="{{'horizontal_label_class'|bootstrap_settings}}"></div>
             <div class="{{'horizontal_field_class'|bootstrap_settings}}">

+ 6 - 0
coderedcms/templates/coderedcms/pages/stream_form_page.html

@@ -39,6 +39,12 @@
 
         {% endblock %}
 
+        {% block captcha %}
+            {% if page.spam_protection %}
+                {% include 'coderedcms/includes/form_honeypot.html' %}
+            {% endif %}
+        {% endblock %}
+
         {% block stream_form_actions %}
         <div class="form-group mt-5 row">
             <div class="{{'horizontal_label_class'|bootstrap_settings}}"></div>

+ 25 - 3
coderedcms/templatetags/coderedcms_tags.py

@@ -134,9 +134,12 @@ def structured_data_datetime(dt):
     """
     Formats datetime object to structured data compatible datetime string.
     """
-    if dt.time():
-        return datetime.strftime(dt, "%Y-%m-%dT%H:%M")
-    return datetime.strftime(dt, "%Y-%m-%d")
+    try:
+        if dt.time():
+            return datetime.strftime(dt, "%Y-%m-%dT%H:%M")
+        return datetime.strftime(dt, "%Y-%m-%d")
+    except AttributeError:
+        return ""
 
 
 @register.filter
@@ -168,3 +171,22 @@ def render_iframe_from_embed(embed):
         pass
 
     return mark_safe(embed.html)
+
+
+@register.filter
+def map_to_bootstrap_alert(message_tag):
+    """
+    Converts a message level to a bootstrap 4 alert class
+    """
+    message_to_alert_dict = {
+        'debug': 'primary',
+        'info': 'info',
+        'success': 'success',
+        'warning': 'warning',
+        'error': 'danger'
+    }
+
+    try:
+        return message_to_alert_dict[message_tag]
+    except KeyError:
+        return ''

+ 4 - 1
coderedcms/tests/settings.py

@@ -211,4 +211,7 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
 
 WAGTAIL_CACHE = False
 
-SECRET_KEY = 'not needed'
+SECRET_KEY = 'not needed'
+
+NOSE_ARGS = ['--nocapture',
+             '--nologcapture', ]

+ 1 - 0
coderedcms/tests/testapp/migrations/0001_initial.py

@@ -58,6 +58,7 @@ class Migration(migrations.Migration):
                 ('form_id', models.CharField(blank=True, help_text='Custom ID applied to <form> element.', max_length=255, verbose_name='Form ID')),
                 ('form_golive_at', models.DateTimeField(blank=True, help_text='Date and time when the FORM goes live on the page.', null=True, verbose_name='Form go live date/time')),
                 ('form_expire_at', models.DateTimeField(blank=True, help_text='Date and time when the FORM will no longer be available on the page.', null=True, verbose_name='Form expiry date/time')),
+                ('spam_protection', models.BooleanField(default=True, help_text='When enabled, the CMS will filter out spam form submissions for this page.', verbose_name='Spam Protection')),
                 ('thank_you_page', models.ForeignKey(blank=True, help_text='The page users are redirected to after submitting the form.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailcore.Page', verbose_name='Thank you page')),
             ],
             options={

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
coderedcms/tests/testapp/migrations/0004_streamformconfirmemail_streamformpage.py


+ 7 - 2
coderedcms/tests/testapp/models.py

@@ -21,7 +21,7 @@ class ArticlePage(CoderedArticlePage):
     """
     class Meta:
         verbose_name = 'Article'
-        ordering = ['-first_published_at',]
+        ordering = ['-first_published_at', ]
 
     # Only allow this page to be created beneath an ArticleIndexPage.
     parent_page_types = ['testapp.ArticleIndexPage']
@@ -67,6 +67,7 @@ class FormPageField(CoderedFormField):
 
     page = ParentalKey('FormPage', related_name='form_fields')
 
+
 class FormConfirmEmail(CoderedEmail):
     """
     Sends a confirmation email after submitting a FormPage.
@@ -84,6 +85,7 @@ class WebPage(CoderedWebPage):
 
     template = 'coderedcms/pages/web_page.html'
 
+
 class EventPage(CoderedEventPage):
     class Meta:
         verbose_name = 'Event Page'
@@ -112,6 +114,7 @@ class EventIndexPage(CoderedEventIndexPage):
 class EventOccurrence(CoderedEventOccurrence):
     event = ParentalKey(EventPage, related_name='occurrences')
 
+
 class LocationPage(CoderedLocationPage):
     """
     A page that holds a location.  This could be a store, a restaurant, etc.
@@ -141,11 +144,13 @@ class LocationIndexPage(CoderedLocationIndexPage):
 
     template = 'coderedcms/pages/location_index_page.html'
 
+
 class StreamFormPage(CoderedStreamFormPage):
     class Meta:
         verbose_name = 'Stream Form'
 
     template = 'coderedcms/pages/stream_form_page.html'
 
+
 class StreamFormConfirmEmail(CoderedEmail):
-    page = ParentalKey('StreamFormPage', related_name='confirmation_emails')
+    page = ParentalKey('StreamFormPage', related_name='confirmation_emails')

+ 2 - 1
docs/features/page_types/form_pages.rst

@@ -38,4 +38,5 @@ Settings Tab
 ~~~~~~~~~~~~
 
 * **Form go live date/time**: The optional date/time the form will start appearing on the page.
-* **Form expiry date/time**: The optional date/time the form will stop appearing on the page.
+* **Form expiry date/time**: The optional date/time the form will stop appearing on the page.
+* **Spam Protection**: When toggled on, this will engage spam protection techniques to attempt to reduce spam form submissions.

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác