# HG changeset patch # User Brian Neal # Date 1275108688 0 # Node ID 8c1832b9d81552a19f1efda524982c30efd28a9e # Parent 28988cce138bddf60b0f193ae1fbb50a486124e0 Implement #84; additional checks on spammers; implement stranger status. diff -r 28988cce138b -r 8c1832b9d815 gpp/antispam/__init__.py --- a/gpp/antispam/__init__.py Fri May 14 02:19:48 2010 +0000 +++ b/gpp/antispam/__init__.py Sat May 29 04:51:28 2010 +0000 @@ -1,1 +1,4 @@ SPAM_PHRASE_KEY = "antispam.spam_phrases" +BUSTED_MESSAGE = ("Your post has tripped our spam filter. Your account has " + "been suspended pending a review of your post. If this was a mistake " + "then we apologize; your account will be restored shortly.") diff -r 28988cce138b -r 8c1832b9d815 gpp/antispam/models.py --- a/gpp/antispam/models.py Fri May 14 02:19:48 2010 +0000 +++ b/gpp/antispam/models.py Sat May 29 04:51:28 2010 +0000 @@ -19,4 +19,5 @@ def save(self, *args, **kwargs): cache.delete(SPAM_PHRASE_KEY) + self.phrase = self.phrase.lower() super(SpamPhrase, self).save(*args, **kwargs) diff -r 28988cce138b -r 8c1832b9d815 gpp/antispam/urls.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/gpp/antispam/urls.py Sat May 29 04:51:28 2010 +0000 @@ -0,0 +1,6 @@ +"""URLs for the antispam application.""" +from django.conf.urls.defaults import * + +urlpatterns = patterns('antispam.views', + url(r'^suspended/$', 'suspended', name='antispam-suspended'), +) diff -r 28988cce138b -r 8c1832b9d815 gpp/antispam/utils.py --- a/gpp/antispam/utils.py Fri May 14 02:19:48 2010 +0000 +++ b/gpp/antispam/utils.py Sat May 29 04:51:28 2010 +0000 @@ -1,8 +1,14 @@ """Antispam utility functions other apps can use.""" +import datetime +import logging +import textwrap + from django.core.cache import cache from antispam import SPAM_PHRASE_KEY from antispam.models import SpamPhrase +from core.functions import email_admins +from bio.models import STA_SUSPENDED def contains_spam(s): @@ -10,6 +16,7 @@ phrases and False otherwise. """ phrases = _get_spam_phrases() + s = s.lower() for spam in phrases: if spam in s: return True @@ -17,6 +24,45 @@ return False +def spam_check(request, content): + """This function checks the supplied content for spam if the user from the + supplied request is a stranger (new to the site). If spam is found, the + function makes a log entry, emails the admins, suspends the user's account + and returns True. If spam is not found, False is returned. + It is assumed that request.user is an authenticated user and thus has a + user profile. + """ + user = request.user + if user.get_profile().is_stranger() and contains_spam(content): + + ip = request.META.get('REMOTE_ADDR', "unknown") + + msg = textwrap.dedent("""\ + SPAM FILTER TRIPPED by %s + PATH: %s + IP: %s + Message: + %s + """ % (user.username, request.path, ip, content)) + + logging.info(msg) + email_admins("SPAM FILTER TRIPPED BY %s" % user.username, msg) + suspend_user(user) + return True + + return False + + +def suspend_user(user): + """This function marks the user as suspended.""" + user.is_active = False + user.save() + profile = user.get_profile() + profile.status = STA_SUSPENDED + profile.status_date = datetime.datetime.now() + profile.save() + + def _get_spam_phrases(): """This function returns the current list of spam phrase strings. The strings are cached to avoid hitting the database. @@ -28,4 +74,3 @@ phrases = SpamPhrase.objects.values_list('phrase', flat=True) cache.set(SPAM_PHRASE_KEY, phrases) return phrases - diff -r 28988cce138b -r 8c1832b9d815 gpp/antispam/views.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/gpp/antispam/views.py Sat May 29 04:51:28 2010 +0000 @@ -0,0 +1,18 @@ +"""Views for the antispam application.""" + +from django.shortcuts import render_to_response +from django.template import RequestContext + +import bio.models + + +def suspended(request): + """This view checks the user's status for suspension and displays an + appropriate message. + """ + is_active = request.user.is_active + + return render_to_response('antispam/suspended.html', { + 'is_active': is_active, + }, + context_instance = RequestContext(request)) diff -r 28988cce138b -r 8c1832b9d815 gpp/bio/admin.py --- a/gpp/bio/admin.py Fri May 14 02:19:48 2010 +0000 +++ b/gpp/bio/admin.py Sat May 29 04:51:28 2010 +0000 @@ -20,7 +20,8 @@ search_fields = ('user__username', 'user__first_name', 'user__last_name', 'user__email') exclude = ('profile_html', 'signature_html') - list_display = ('__unicode__', 'get_status_display', 'status_date') + list_display = ('__unicode__', 'user_is_active', 'get_status_display', 'status_date') + readonly_fields = ('status', 'status_date') list_filter = ('status', ) date_hierarchy = 'status_date' inlines = (BadgeOwnerInline, ) @@ -30,6 +31,7 @@ 'mark_removed', 'mark_suspended', 'mark_spammer', + 'mark_stranger', ) def get_status_display(self, obj): @@ -44,7 +46,8 @@ """ now = datetime.datetime.now() for profile in qs: - profile.user.is_active = status == bio.models.STA_ACTIVE + profile.user.is_active = (status == bio.models.STA_ACTIVE or + status == bio.models.STA_STRANGER) profile.user.save() profile.status = status profile.status_date = now @@ -94,6 +97,13 @@ delete_user_posts(profile.user) mark_spammer.short_description = "Mark selected users as spammers" + def mark_stranger(self, request, qs): + """ + Marks users as strangers. Updates their profile status to STA_STRANGER. + """ + self.mark_user_status(request, qs, bio.models.STA_STRANGER) + mark_stranger.short_description = "Mark selected users as strangers" + class UserProfileFlagAdmin(admin.ModelAdmin): list_display = ('__unicode__', 'flag_date', 'get_profile_url') diff -r 28988cce138b -r 8c1832b9d815 gpp/bio/models.py --- a/gpp/bio/models.py Fri May 14 02:19:48 2010 +0000 +++ b/gpp/bio/models.py Sat May 29 04:51:28 2010 +0000 @@ -12,8 +12,17 @@ from core.markup import SiteMarkup - -(STA_ACTIVE, STA_RESIGNED, STA_REMOVED, STA_SUSPENDED, STA_SPAMMER) = range(5) +# These are the secondary user status enumeration values. +(STA_ACTIVE, # User is a full member in good standing. + STA_RESIGNED, # User has voluntarily asked to be removed. + STA_REMOVED, # User was removed for bad behavior. + STA_SUSPENDED, # User is temporarily suspended; e.g. a stranger tripped + # the spam filter. + STA_SPAMMER, # User has been removed for spamming. + STA_STRANGER, # New member, isn't fully trusted yet. Their comments and + # forum posts are scanned for spam. They can have their + # accounts deactivated by moderators for spamming. + ) = range(6) USER_STATUS_CHOICES = ( (STA_ACTIVE, "Active"), @@ -21,6 +30,7 @@ (STA_REMOVED, "Removed"), (STA_SUSPENDED, "Suspended"), (STA_SPAMMER, "Spammer"), + (STA_STRANGER, "Stranger") ) @@ -76,7 +86,7 @@ default='US/Pacific') use_24_time = models.BooleanField(default=False) forum_post_count = models.IntegerField(default=0) - status = models.IntegerField(default=STA_ACTIVE, + status = models.IntegerField(default=STA_STRANGER, choices=USER_STATUS_CHOICES) status_date = models.DateTimeField(auto_now_add=True) badges = models.ManyToManyField(Badge, through="BadgeOwnership") @@ -105,6 +115,18 @@ "badge") return self._badges + def is_stranger(self): + """Returns True if this user profile status is STA_STRANGER.""" + return self.status == STA_STRANGER + + def user_is_active(self): + """Returns the profile's user is_active status. This function exists + for the admin. + """ + return self.user.is_active + user_is_active.boolean = True + user_is_active.short_description = "Is Active" + class UserProfileFlag(models.Model): """This model represents a user flagging a profile as inappropriate.""" diff -r 28988cce138b -r 8c1832b9d815 gpp/comments/views.py --- a/gpp/comments/views.py Fri May 14 02:19:48 2010 +0000 +++ b/gpp/comments/views.py Sat May 29 04:51:28 2010 +0000 @@ -18,13 +18,16 @@ from comments.forms import CommentForm from comments.models import Comment from comments.models import CommentFlag +import antispam +import antispam.utils + @login_required @require_POST def post_comment(request): """ This function handles the posting of comments. If successful, returns - the comment text as the response. This function is mean't to be the target + the comment text as the response. This function is meant to be the target of an AJAX post. """ # Look up the object we're trying to comment about @@ -66,9 +69,13 @@ if not form.is_valid(): return HttpResponseBadRequest('Invalid comment; missing parameters?') - # else, create and save the comment + comment = form.get_comment_object(request.user, request.META.get("REMOTE_ADDR", None)) - comment = form.get_comment_object(request.user, request.META.get("REMOTE_ADDR", None)) + # Check for spam + + if antispam.utils.spam_check(request, comment.comment): + return HttpResponseForbidden(antispam.BUSTED_MESSAGE) + comment.save() # return the rendered comment diff -r 28988cce138b -r 8c1832b9d815 gpp/core/middleware.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/gpp/core/middleware.py Sat May 29 04:51:28 2010 +0000 @@ -0,0 +1,16 @@ +"""Common middleware for the entire project.""" +from django.contrib.auth import logout + +class InactiveUserMiddleware(object): + """ + This middleware ensures users with is_active set to False get their + session destroyed and are treated as logged out. + This middleware should come after the 'django.contrib.auth.middleware. + AuthenticationMiddleware' in settings.py. + Idea taken from: http://djangosnippets.org/snippets/1105/ + """ + + def process_request(self, request): + if request.user.is_authenticated() and not request.user.is_active: + logout(request) + diff -r 28988cce138b -r 8c1832b9d815 gpp/forums/spam.py --- a/gpp/forums/spam.py Fri May 14 02:19:48 2010 +0000 +++ b/gpp/forums/spam.py Sat May 29 04:51:28 2010 +0000 @@ -2,10 +2,13 @@ This module contains views for dealing with spam and spammers. """ import datetime +import logging +import textwrap from django.contrib.auth.decorators import login_required from django.core.urlresolvers import reverse from django.http import HttpResponseRedirect +from django.http import HttpResponseForbidden from django.shortcuts import get_object_or_404 from django.shortcuts import render_to_response from django.template import RequestContext @@ -39,28 +42,51 @@ delete_user_posts(user) +def promote_stranger(user): + """This function upgrades the user from stranger status to a regular user. + """ + profile = user.get_profile() + if user.is_active and profile.status == bio.models.STA_STRANGER: + profile.status = bio.models.STA_ACTIVE + profile.status_date = datetime.datetime.now() + profile.save() + + @login_required def spammer(request, post_id): """This view allows moderators to deactivate spammer accounts.""" post = get_object_or_404(Post.objects.select_related(), pk=post_id) + poster = post.user + poster_profile = poster.get_profile() can_moderate = request.user.is_superuser or ( request.user in post.topic.forum.moderators.all()) - if request.method == "POST": - if can_moderate: - deactivate_spammer(post.user) + can_deactivate = (poster_profile.status == bio.models.STA_STRANGER and not + poster.is_superuser) - email_admins(SPAMMER_NAILED_SUBJECT % post.user.username, - SPAMMER_NAILED_MSG_BODY % ( - request.user.username, post.user.username)) + if request.method == "POST" and can_moderate and can_deactivate: + deactivate_spammer(poster) - return HttpResponseRedirect(reverse('forums-spammer_nailed', args=[ - post.user.id])) + email_admins(SPAMMER_NAILED_SUBJECT % poster.username, + SPAMMER_NAILED_MSG_BODY % ( + request.user.username, poster.username)) + + logging.info(textwrap.dedent("""\ + SPAMMER DEACTIVATED: %s nailed %s for spam. + IP: %s + Message: + %s + """ % (request.user.username, poster.username, post.user_ip, + post.body))) + + return HttpResponseRedirect(reverse('forums-spammer_nailed', args=[ + poster.id])) return render_to_response('forums/spammer.html', { 'can_moderate': can_moderate, + 'can_deactivate': can_deactivate, 'post': post, }, context_instance=RequestContext(request)) @@ -81,3 +107,33 @@ 'success': success, }, context_instance=RequestContext(request)) + + +@login_required +def stranger(request, post_id): + """This view allows a forum moderator or super user to promote a user from + stranger status to regular user. + """ + post = get_object_or_404(Post.objects.select_related(), pk=post_id) + poster = post.user + poster_profile = poster.get_profile() + + can_moderate = request.user.is_superuser or ( + request.user in post.topic.forum.moderators.all()) + + can_promote = poster_profile.status == bio.models.STA_STRANGER + + if request.method == "POST" and can_moderate and can_promote: + promote_stranger(poster) + + logging.info("STRANGER PROMOTED: %s promoted %s." % ( + request.user.username, poster.username)) + + return HttpResponseRedirect(post.get_absolute_url()) + + return render_to_response('forums/stranger.html', { + 'can_moderate': can_moderate, + 'can_promote': can_promote, + 'post': post, + }, + context_instance=RequestContext(request)) diff -r 28988cce138b -r 8c1832b9d815 gpp/forums/urls.py --- a/gpp/forums/urls.py Fri May 14 02:19:48 2010 +0000 +++ b/gpp/forums/urls.py Sat May 29 04:51:28 2010 +0000 @@ -38,4 +38,5 @@ urlpatterns += patterns('forums.spam', url(r'^spammer/(\d+)/$', 'spammer', name='forums-spammer'), url(r'^spammer/nailed/(\d+)/$', 'spammer_nailed', name='forums-spammer_nailed'), + url(r'^stranger/(\d+)/$', 'stranger', name='forums-stranger'), ) diff -r 28988cce138b -r 8c1832b9d815 gpp/forums/views.py --- a/gpp/forums/views.py Fri May 14 02:19:48 2010 +0000 +++ b/gpp/forums/views.py Sat May 29 04:51:28 2010 +0000 @@ -31,6 +31,9 @@ get_post_unread_status, get_unread_topics from bio.models import UserProfile +import antispam +import antispam.utils + ####################################################################### TOPICS_PER_PAGE = 50 @@ -225,6 +228,9 @@ if request.method == 'POST': form = NewTopicForm(request.user, forum, request.POST) if form.is_valid(): + if antispam.utils.spam_check(request, form.cleaned_data['body']): + return HttpResponseRedirect(reverse('antispam-suspended')) + topic = form.save(request.META.get("REMOTE_ADDR")) _bump_post_count(request.user) return HttpResponseRedirect(reverse('forums-new_topic_thanks', @@ -267,6 +273,8 @@ if form.is_valid(): if not _can_post_in_topic(form.topic, request.user): return HttpResponseForbidden("You don't have permission to post in this topic.") + if antispam.utils.spam_check(request, form.cleaned_data['body']): + return HttpResponseForbidden(antispam.BUSTED_MESSAGE) post = form.save(request.user, request.META.get("REMOTE_ADDR", "")) post.unread = True @@ -342,6 +350,8 @@ if request.method == "POST": form = PostForm(request.POST, instance=post) if form.is_valid(): + if antispam.utils.spam_check(request, form.cleaned_data['body']): + return HttpResponseRedirect(reverse('antispam-suspended')) post = form.save(commit=False) post.touch() post.save() @@ -462,6 +472,8 @@ if request.method == 'POST': form = PostForm(request.POST) if form.is_valid(): + if antispam.utils.spam_check(request, form.cleaned_data['body']): + return HttpResponseRedirect(reverse('antispam-suspended')) post = form.save(commit=False) post.topic = topic post.user = request.user diff -r 28988cce138b -r 8c1832b9d815 gpp/settings.py --- a/gpp/settings.py Fri May 14 02:19:48 2010 +0000 +++ b/gpp/settings.py Sat May 29 04:51:28 2010 +0000 @@ -81,8 +81,9 @@ 'django.contrib.messages.middleware.MessageMiddleware', 'debug_toolbar.middleware.DebugToolbarMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'gpp.core.middleware.InactiveUserMiddleware', + 'gpp.forums.middleware.WhosOnline', 'django.contrib.flatpages.middleware.FlatpageFallbackMiddleware', - 'gpp.forums.middleware.WhosOnline', ) else: MIDDLEWARE_CLASSES = ( @@ -91,8 +92,9 @@ 'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'gpp.core.middleware.InactiveUserMiddleware', + 'gpp.forums.middleware.WhosOnline', 'django.contrib.flatpages.middleware.FlatpageFallbackMiddleware', - 'gpp.forums.middleware.WhosOnline', ) ROOT_URLCONF = 'gpp.urls' diff -r 28988cce138b -r 8c1832b9d815 gpp/templates/antispam/suspended.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/gpp/templates/antispam/suspended.html Sat May 29 04:51:28 2010 +0000 @@ -0,0 +1,19 @@ +{% extends 'base.html' %} +{% block title %}Account Suspension Status{% endblock %} +{% block content %} +

Account Suspension Status

+{% if not is_active %} +

+Your post has tripped our spam filter. Your account has been suspended pending +a review of your post. If this was a mistake, we sincerely apologize and promise +your account will be restored soon. We regret having to take this drastic measure, but +the high volume of spam we recieve has forced us to take this action. If you have any +questions, please feel to contact us. +

+{% else %} +

+Your account has been restored and you should be able to make comments and forum +posts. We apologize for any inconvenience. +

+{% endif %} +{% endblock %} diff -r 28988cce138b -r 8c1832b9d815 gpp/templates/comments/comment.html --- a/gpp/templates/comments/comment.html Fri May 14 02:19:48 2010 +0000 +++ b/gpp/templates/comments/comment.html Sat May 29 04:51:28 2010 +0000 @@ -1,6 +1,4 @@ {% load avatar_tags %} -{% load markup %} -{% load smiley_tags %}
{{ forloop.counter }}.
diff -r 28988cce138b -r 8c1832b9d815 gpp/templates/forums/display_post.html --- a/gpp/templates/forums/display_post.html Fri May 14 02:19:48 2010 +0000 +++ b/gpp/templates/forums/display_post.html Sat May 29 04:51:28 2010 +0000 @@ -23,7 +23,7 @@ {% endif %} -