Add some spam protection features

This adds validation to the project description field
spam
Anthony Johnson 2015-11-22 17:45:34 -08:00
parent 045843a85e
commit f9165ed408
11 changed files with 169 additions and 48 deletions

View File

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0002_make_userprofile_user_a_onetoonefield'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='banned',
field=models.BooleanField(default=False, verbose_name='Banned'),
),
]

View File

@ -18,6 +18,7 @@ class UserProfile (models.Model):
user = models.OneToOneField('auth.User', verbose_name=_('User'), user = models.OneToOneField('auth.User', verbose_name=_('User'),
related_name='profile') related_name='profile')
whitelisted = models.BooleanField(_('Whitelisted'), default=False) whitelisted = models.BooleanField(_('Whitelisted'), default=False)
banned = models.BooleanField(_('Banned'), default=False)
homepage = models.CharField(_('Homepage'), max_length=100, blank=True) homepage = models.CharField(_('Homepage'), max_length=100, blank=True)
allow_email = models.BooleanField(_('Allow email'), allow_email = models.BooleanField(_('Allow email'),
help_text=_('Show your email on VCS ' help_text=_('Show your email on VCS '

View File

@ -6,3 +6,10 @@ class ProjectImportError (Exception):
"""Failure to import a project from a repository.""" """Failure to import a project from a repository."""
pass pass
class ProjectSpamError(Exception):
"""Error raised when a project field has detected spam"""
pass

View File

@ -10,6 +10,7 @@ from django.template.defaultfilters import slugify
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from textclassifier.validators import ClassifierValidator
from guardian.shortcuts import assign from guardian.shortcuts import assign
@ -17,6 +18,7 @@ from readthedocs.builds.constants import TAG
from readthedocs.core.utils import trigger_build from readthedocs.core.utils import trigger_build
from readthedocs.redirects.models import Redirect from readthedocs.redirects.models import Redirect
from readthedocs.projects import constants from readthedocs.projects import constants
from readthedocs.projects.exceptions import ProjectSpamError
from readthedocs.projects.models import Project, EmailHook, WebHook, Domain from readthedocs.projects.models import Project, EmailHook, WebHook, Domain
from readthedocs.privacy.loader import AdminPermission from readthedocs.privacy.loader import AdminPermission
@ -130,6 +132,11 @@ class ProjectExtraForm(ProjectForm):
'tags', 'tags',
) )
description = forms.CharField(
validators=[ClassifierValidator(raises=ProjectSpamError)],
widget=forms.Textarea
)
class ProjectAdvancedForm(ProjectTriggerBuildMixin, ProjectForm): class ProjectAdvancedForm(ProjectTriggerBuildMixin, ProjectForm):

View File

@ -4,6 +4,7 @@ from django.conf.urls import patterns, url
from readthedocs.projects.views.private import ( from readthedocs.projects.views.private import (
ProjectDashboard, ImportView, ProjectDashboard, ImportView,
ProjectUpdate, ProjectAdvancedUpdate,
DomainList, DomainCreate, DomainDelete, DomainUpdate) DomainList, DomainCreate, DomainDelete, DomainUpdate)
from readthedocs.projects.backends.views import ImportWizardView, ImportDemoView from readthedocs.projects.backends.views import ImportWizardView, ImportDemoView
@ -37,11 +38,11 @@ urlpatterns = patterns(
name='projects_comments_moderation'), name='projects_comments_moderation'),
url(r'^(?P<project_slug>[-\w]+)/edit/$', url(r'^(?P<project_slug>[-\w]+)/edit/$',
'readthedocs.projects.views.private.project_edit', ProjectUpdate.as_view(),
name='projects_edit'), name='projects_edit'),
url(r'^(?P<project_slug>[-\w]+)/advanced/$', url(r'^(?P<project_slug>[-\w]+)/advanced/$',
'readthedocs.projects.views.private.project_advanced', ProjectAdvancedUpdate.as_view(),
name='projects_advanced'), name='projects_advanced'),
url(r'^(?P<project_slug>[-\w]+)/version/(?P<version_slug>[^/]+)/delete_html/$', url(r'^(?P<project_slug>[-\w]+)/version/(?P<version_slug>[^/]+)/delete_html/$',

View File

@ -1,7 +1,17 @@
import logging
from datetime import datetime, timedelta
from django.conf import settings
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect
from readthedocs.projects.models import Project from ..models import Project
from ..exceptions import ProjectSpamError
log = logging.getLogger(__name__)
USER_MATURITY_DAYS = getattr(settings, 'USER_MATURITY_DAYS', 7)
class ProjectOnboardMixin(object): class ProjectOnboardMixin(object):
@ -71,3 +81,29 @@ class ProjectAdminMixin(object):
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse('projects_domains', args=[self.get_project().slug]) return reverse('projects_domains', args=[self.get_project().slug])
class ProjectSpamMixin(object):
"""Protects POST views from spammers"""
def post(self, request, *args, **kwargs):
if request.user.profile.banned:
log.error('Rejecting project POST from shadowbanned user %s',
request.user)
return HttpResponseRedirect(self.get_failure_url())
try:
return super(ProjectSpamMixin, self).post(request, *args, **kwargs)
except ProjectSpamError:
date_maturity = datetime.now() - timedelta(days=USER_MATURITY_DAYS)
if request.user.date_joined > date_maturity:
request.user.profile.banned = True
request.user.profile.save()
log.error('Spam detected from new user, shadowbanned user %s',
request.user)
else:
log.error('Spam detected from user %s', request.user)
return HttpResponseRedirect(self.get_failure_url())
def get_failure_url(self):
return reverse('homepage')

View File

@ -32,8 +32,9 @@ from readthedocs.projects.forms import (
build_versions_form, UserForm, EmailHookForm, TranslationForm, build_versions_form, UserForm, EmailHookForm, TranslationForm,
RedirectForm, WebHookForm, DomainForm) RedirectForm, WebHookForm, DomainForm)
from readthedocs.projects.models import Project, EmailHook, WebHook, Domain from readthedocs.projects.models import Project, EmailHook, WebHook, Domain
from readthedocs.projects.views.base import ProjectAdminMixin from readthedocs.projects.views.base import ProjectAdminMixin, ProjectSpamMixin
from readthedocs.projects import constants, tasks from readthedocs.projects import constants, tasks
from readthedocs.projects.exceptions import ProjectSpamError
from readthedocs.projects.tasks import remove_dir, clear_artifacts from readthedocs.projects.tasks import remove_dir, clear_artifacts
from readthedocs.core.mixins import LoginRequiredMixin from readthedocs.core.mixins import LoginRequiredMixin
@ -95,58 +96,37 @@ def project_comments_moderation(request, project_slug):
{'project': project}) {'project': project})
@login_required class ProjectUpdate(ProjectSpamMixin, PrivateViewMixin, UpdateView):
def project_edit(request, project_slug):
"""Project edit view
Edit an existing project - depending on what type of project is being
edited (created or imported) a different form will be displayed
"""
project = get_object_or_404(Project.objects.for_admin_user(request.user),
slug=project_slug)
form_class = UpdateProjectForm form_class = UpdateProjectForm
model = Project
success_message = _('Project settings updated')
template_name = 'projects/project_edit.html'
lookup_url_kwarg = 'project_slug'
lookup_field = 'slug'
form = form_class(instance=project, data=request.POST or None, def get_queryset(self):
user=request.user) return self.model.objects.for_admin_user(self.request.user)
if request.method == 'POST' and form.is_valid(): def get_success_url(self):
form.save() return reverse('projects_detail', args=[self.object.slug])
messages.success(request, _('Project settings updated'))
project_dashboard = reverse('projects_detail', args=[project.slug])
return HttpResponseRedirect(project_dashboard)
return render_to_response(
'projects/project_edit.html',
{'form': form, 'project': project},
context_instance=RequestContext(request)
)
@login_required class ProjectAdvancedUpdate(ProjectSpamMixin, PrivateViewMixin, UpdateView):
def project_advanced(request, project_slug):
"""Project advanced admin view
Edit an existing project - depending on what type of project is being
edited (created or imported) a different form will be displayed
"""
project = get_object_or_404(Project.objects.for_admin_user(request.user),
slug=project_slug)
form_class = ProjectAdvancedForm form_class = ProjectAdvancedForm
form = form_class(instance=project, data=request.POST or None, initial={ model = Project
'num_minor': 2, 'num_major': 2, 'num_point': 2}) success_message = _('Project settings updated')
template_name = 'projects/project_advanced.html'
lookup_url_kwarg = 'project_slug'
lookup_field = 'slug'
initial = {'num_minor': 2, 'num_major': 2, 'num_point': 2}
if request.method == 'POST' and form.is_valid(): def get_queryset(self):
form.save() return self.model.objects.for_admin_user(self.request.user)
messages.success(request, _('Project settings updated'))
project_dashboard = reverse('projects_detail', args=[project.slug])
return HttpResponseRedirect(project_dashboard)
return render_to_response( def get_success_url(self):
'projects/project_advanced.html', return reverse('projects_detail', args=[self.object.slug])
{'form': form, 'project': project},
context_instance=RequestContext(request)
)
@login_required @login_required
@ -240,7 +220,7 @@ def project_delete(request, project_slug):
) )
class ImportWizardView(PrivateViewMixin, SessionWizardView): class ImportWizardView(ProjectSpamMixin, PrivateViewMixin, SessionWizardView):
"""Project import wizard""" """Project import wizard"""

View File

@ -0,0 +1,24 @@
import mock
from django.test import TestCase, override_settings
from textclassifier.validators import ClassifierValidator
from readthedocs.projects.exceptions import ProjectSpamError
from readthedocs.projects.forms import ProjectExtraForm
class TestProjectForms(TestCase):
@mock.patch.object(ClassifierValidator, '__call__')
def test_form_spam(self, mocked_validator):
"""Form description field fails spam validation"""
mocked_validator.side_effect = ProjectSpamError
data = {
'description': 'foo',
'documentation_type': 'sphinx',
'language': 'en',
}
form = ProjectExtraForm(data)
with self.assertRaises(ProjectSpamError):
form.is_valid()

View File

@ -1,11 +1,15 @@
from datetime import datetime, timedelta
import mock
from mock import patch
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.messages import constants as message_const from django.contrib.messages import constants as message_const
from django_dynamic_fixture import get from django_dynamic_fixture import get
from django_dynamic_fixture import new from django_dynamic_fixture import new
from mock import patch
from readthedocs.rtd_tests.base import WizardTestCase, MockBuildTestCase from readthedocs.rtd_tests.base import WizardTestCase, MockBuildTestCase
from readthedocs.projects.models import Project from readthedocs.projects.models import Project
from readthedocs.projects.exceptions import ProjectSpamError
class TestBasicsForm(WizardTestCase): class TestBasicsForm(WizardTestCase):
@ -81,6 +85,46 @@ class TestAdvancedForm(TestBasicsForm):
self.assertWizardFailure(resp, 'language') self.assertWizardFailure(resp, 'language')
self.assertWizardFailure(resp, 'documentation_type') self.assertWizardFailure(resp, 'documentation_type')
@patch('readthedocs.projects.forms.ProjectExtraForm.clean_description',
create=True)
def test_form_spam(self, mocked_validator):
'''Don't add project on a spammy description'''
self.eric.date_joined = datetime.now() - timedelta(days=365)
self.eric.save()
mocked_validator.side_effect=ProjectSpamError
with self.assertRaises(Project.DoesNotExist):
proj = Project.objects.get(name='foobar')
resp = self.post_step('basics')
self.assertWizardResponse(resp, 'extra')
resp = self.post_step('extra')
self.assertWizardResponse(resp)
with self.assertRaises(Project.DoesNotExist):
proj = Project.objects.get(name='foobar')
self.assertFalse(self.eric.profile.banned)
@patch('readthedocs.projects.forms.ProjectExtraForm.clean_description',
create=True)
def test_form_spam_ban_user(self, mocked_validator):
'''Don't add spam and ban new user'''
self.eric.date_joined = datetime.now()
self.eric.save()
mocked_validator.side_effect=ProjectSpamError
with self.assertRaises(Project.DoesNotExist):
proj = Project.objects.get(name='foobar')
resp = self.post_step('basics')
self.assertWizardResponse(resp, 'extra')
resp = self.post_step('extra')
self.assertWizardResponse(resp)
with self.assertRaises(Project.DoesNotExist):
proj = Project.objects.get(name='foobar')
self.assertTrue(self.eric.profile.banned)
class TestImportDemoView(MockBuildTestCase): class TestImportDemoView(MockBuildTestCase):
'''Test project import demo view''' '''Test project import demo view'''

View File

@ -193,6 +193,7 @@ INSTALLED_APPS = [
'rest_framework', 'rest_framework',
'corsheaders', 'corsheaders',
'copyright', 'copyright',
'textclassifier',
# Celery bits # Celery bits
'djcelery', 'djcelery',

View File

@ -52,6 +52,7 @@ django-copyright==1.0.0
django-formtools==1.0 django-formtools==1.0
django-dynamic-fixture==1.8.5 django-dynamic-fixture==1.8.5
docker-py==1.3.1 docker-py==1.3.1
django-textclassifier==1.0
# Docs # Docs
sphinx-http-domain==0.2 sphinx-http-domain==0.2