Mercurial > public > sg101
changeset 215:8c1832b9d815
Implement #84; additional checks on spammers; implement stranger status.
author | Brian Neal <bgneal@gmail.com> |
---|---|
date | Sat, 29 May 2010 04:51:28 +0000 |
parents | 28988cce138b |
children | fe900598f81c |
files | gpp/antispam/__init__.py gpp/antispam/models.py gpp/antispam/urls.py gpp/antispam/utils.py gpp/antispam/views.py gpp/bio/admin.py gpp/bio/models.py gpp/comments/views.py gpp/core/middleware.py gpp/forums/spam.py gpp/forums/urls.py gpp/forums/views.py gpp/settings.py gpp/templates/antispam/suspended.html gpp/templates/comments/comment.html gpp/templates/forums/display_post.html gpp/templates/forums/spammer.html gpp/templates/forums/stranger.html gpp/urls.py media/css/base.css media/icons/tick.png |
diffstat | 21 files changed, 288 insertions(+), 25 deletions(-) [+] |
line wrap: on
line diff
--- 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.")
--- 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)
--- /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'), +)
--- 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 -
--- /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))
--- 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')
--- 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."""
--- 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
--- /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) +
--- 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))
--- 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'), )
--- 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
--- 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'
--- /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 %} +<h2>Account Suspension Status</h2> +{% if not is_active %} +<p class="error"> +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 <a href="{% url contact-form %}">contact us</a>. +</p> +{% else %} +<p class="success"> +Your account has been restored and you should be able to make comments and forum +posts. We apologize for any inconvenience. +</p> +{% endif %} +{% endblock %}
--- 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 %} <div class="comment" id="c{{ comment.id }}"> <div class="comment-list">{{ forloop.counter }}.</div> <div class="comment-avatar">
--- 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 %} </td> <td class="forum-post-body"> - <div class="forum-post-info quiet"> + <div class="forum-post-info quiet{% if post.user_profile.is_stranger %} stranger{% endif %}"> {% if post.unread %}<img src="{{ MEDIA_URL }}icons/new.png" alt="New" title="New" />{% endif %} <a href="{{ post.get_absolute_url }}"><img src="{{ MEDIA_URL }}icons/link.png" alt="Link" title="Link to this post" /></a> Posted on {% forum_date post.creation_date user %} @@ -49,7 +49,11 @@ {% if can_moderate %} <a href="#" class="post-delete" id="dp-{{ post.id }}" title="Delete this post"><img src="{{ MEDIA_URL }}icons/cross.png" alt="Delete post" /></a> - {% if post.user != user %} + {% if post.user != user and post.user_profile.is_stranger %} + <br /> + <span class="quiet">Stranger options:</span> + <a href="{% url forums-stranger post.id %}" title="This stranger seems legitimate"> + <img src="{{ MEDIA_URL }}icons/tick.png" alt="Acquaintance" /></a> <a href="{% url forums-spammer post.id %}" title="This is spam"> <img src="{{ MEDIA_URL }}icons/exclamation.png" alt="Spammer" /></a> {% endif %}
--- a/gpp/templates/forums/spammer.html Fri May 14 02:19:48 2010 +0000 +++ b/gpp/templates/forums/spammer.html Sat May 29 04:51:28 2010 +0000 @@ -3,7 +3,7 @@ {% block content %} <h2>Deactivate Spammer: {{ post.user.username }}</h2> -{% if can_moderate %} +{% if can_moderate and can_deactivate %} <p>Please confirm that you wish to mark the user <a href="{% url bio-view_profile username=post.user.username %}">{{ post.user.username }}</a> as a spammer based on <a href="{% url forums-goto_post post.id %}">this post</a>. @@ -15,7 +15,12 @@ <input type="submit" value="Deactivate {{ post.user.username }}" /> </form> {% else %} -<p>Sorry, but you don't have permission to deactivate spammers in that post's forum.</p> + {% if can_moderate %} + <p>That user is no longer a stranger, and can't be deactivated like this. Please + contact the site admin if that user is now posting spam.</p> + {% else %} + <p>Sorry, but you don't have permission to deactivate spammers in that post's forum.</p> + {% endif %} {% endif %} <hr /> <p>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/gpp/templates/forums/stranger.html Sat May 29 04:51:28 2010 +0000 @@ -0,0 +1,31 @@ +{% extends 'base.html' %} +{% block title %}Promote Stranger: {{ post.user.username }}{% endblock %} +{% block content %} +<h2>Promote Stranger: {{ post.user.username }}</h2> + +{% if can_moderate and can_promote %} +<p>All new users are considered "<em>strangers</em>" until approved by a moderator. +Strangers have their posts automatically scanned for spam phrases. Moderators can also instantly +deactivate stranger accounts if the spam filter does not catch them. If you promote a stranger, +these checks (which are somewhat expensive for the webserver) will no longer be performed, +and moderators won't be able to deactivate them on the spot. You may wish to wait until the user +has posted at least 10 times before making your decision.</p> +<p>Please confirm that you wish to promote the new user +<a href="{% url bio-view_profile username=post.user.username %}">{{ post.user.username }}</a> from +<em>stranger</em> status based on <a href="{% url forums-goto_post post.id %}">this post</a>. +</p> +<form action="." method="post">{% csrf_token %} + <input type="submit" value="Yes, {{ post.user.username }} seems legit and is not a stranger" /> +</form> +{% else %} + {% if can_moderate %} + <p>That user is no longer a stranger, and can't be promoted again.</p> + {% else %} + <p>Sorry, but you don't have permission to promote users in that post's forum.</p> + {% endif %} +{% endif %} +<hr /> +<p> +<a href="{% url forums-goto_post post.id %}">Return to the post</a>. +</p> +{% endblock %}
--- a/gpp/urls.py Fri May 14 02:19:48 2010 +0000 +++ b/gpp/urls.py Sat May 29 04:51:28 2010 +0000 @@ -14,6 +14,7 @@ (r'^admin/doc/', include('django.contrib.admindocs.urls')), (r'^admin/', include(admin.site.urls)), (r'^accounts/', include('accounts.urls')), + (r'^antispam/', include('antispam.urls')), (r'^calendar/', include('gcalendar.urls')), (r'^comments/', include('comments.urls')), (r'^contact/', include('contact.urls')),