Преглед изворни кода

Form Spam Protection (#210)

Adds an option to enable a honeypot on forms (on by default)
Cory Sutyak пре 5 година
родитељ
комит
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
     }
 }
 

Разлика између датотеке није приказан због своје велике величине
+ 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={

Разлика између датотеке није приказан због своје велике величине
+ 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.

Неке датотеке нису приказане због велике количине промена