Index: tracspamfilter/api.py
===================================================================
--- tracspamfilter/api.py	(revision 4114)
+++ tracspamfilter/api.py	(working copy)
@@ -22,7 +22,7 @@
 import textwrap
 import time
 
-from trac.config import BoolOption, IntOption
+from trac.config import BoolOption, IntOption, ExtensionOption
 from trac.core import *
 from trac.db import DatabaseManager
 from trac.env import IEnvironmentSetupParticipant
@@ -61,10 +61,19 @@
         """
 
 
+class IRejectHandler(Interface):
+    """Handle content rejection."""
+
+    def reject_content(req, reason):
+        """Reject content. `reason` is a human readable message describing why
+        the content was rejected. """
+
+
 class FilterSystem(Component):
     strategies = ExtensionPoint(IFilterStrategy)
 
-    implements(IEnvironmentSetupParticipant, IPermissionRequestor)
+    implements(IEnvironmentSetupParticipant, IPermissionRequestor,
+               IRejectHandler)
 
     min_karma = IntOption('spam-filter', 'min_karma', '0',
         """The minimum score required for a submission to be allowed.""")
@@ -81,6 +90,14 @@
         """Whether content submissions by authenticated users should be trusted
         without checking for potential spam or other abuse.""")
 
+    reject_handler = ExtensionOption('spam-filter', 'reject_handler',
+                                     IRejectHandler, 'FilterSystem',
+        """The handler used to reject content.""")
+
+    # IRejectHandler methods
+    def reject_content(self, req, message):
+        raise RejectContent(message)
+
     # Public methods
 
     def test(self, req, author, changes):
@@ -140,7 +157,8 @@
             msg = ', '.join([r[2] for r in reasons if r[1] < 0])
             if msg:
                 msg = ' (%s)' % msg
-            raise RejectContent('Submission rejected as potential spam%s' % msg)
+            self.reject_handler.reject_content(req, 'Submission rejected as '
+                                               'potential spam %s' % msg)
 
     def train(self, req, log_id, spam=True):
         environ = {}
Index: tracspamfilter/captcha/api.py
===================================================================
--- tracspamfilter/captcha/api.py	(revision 0)
+++ tracspamfilter/captcha/api.py	(revision 0)
@@ -0,0 +1,75 @@
+import time
+from pickle import loads, dumps
+from trac.core import *
+from trac.config import *
+from trac.web.chrome import ITemplateProvider
+from trac.web.api import IRequestFilter, IRequestHandler
+from tracspamfilter.api import IRejectHandler
+
+
+class ICaptchaMethod(Interface):
+    """ A captcha implementation. """
+    def generate_captcha(req):
+        """ Return a tuple of `(result, html)`, where `result` is the expected
+        response and `html` is a HTML fragment for displaying the captcha
+        challenge. """
+
+
+class CaptchaVerification(Component):
+    implements(ITemplateProvider, IRequestHandler, IRejectHandler)
+
+    request_handlers = ExtensionPoint(IRequestHandler)
+    captcha = ExtensionOption('spam-filter', 'captcha', ICaptchaMethod,
+                              'ExpressionCaptcha',
+        """ Captcha method to use for verifying humans. """)
+
+    # IRejectHandler methods
+    def reject_content(self, req, message):
+        if not int(req.session.get('captcha_verified', 0)):
+            req.session['reject_reason'] = message
+            req.session['captcha_redirect'] = req.href(req.path_info)
+            req.redirect(req.href.captcha())
+
+    # IRequestHandler methods
+    def match_request(self, req):
+        return req.path_info == '/captcha'
+    
+    def process_request(self, req):
+        if req.method == 'POST':
+            self.env.log.debug('Captcha response: %s (expected %s)' % 
+                (req.args['captcha_response'], req.session['captcha_expected']))
+            if req.args['captcha_response'] == req.session['captcha_expected']:
+                redirect = req.session.get('captcha_redirect', req.href())
+                del req.session['captcha_redirect']
+                del req.session['captcha_expected']
+                req.session['captcha_verified'] = 1
+                req.redirect(redirect)
+            else:
+                req.hdf['error'] = 'Captcha verification failed'
+        else:
+            req.hdf['error'] = req.session.get('reject_reason')
+        result, html = self.captcha.generate_captcha(req)
+        req.hdf['captcha.challenge'] = html
+        req.hdf['captcha.href'] = req.href('captcha')
+        req.session['captcha_expected'] = result
+        req.session.save()
+        return 'verify_captcha.cs', None
+
+    # ITemplateProvider methods
+    def get_templates_dirs(self):
+        """ Return the absolute path of the directory containing the provided
+        ClearSilver templates.  """
+        from pkg_resources import resource_filename
+        return [resource_filename(__name__, 'templates')]
+
+
+    # IRequestFilter methods
+    def pre_process_request(self, req, handler):
+        # TODO Allow restrict_to to specify request paths to allow
+        if not self.trust_authenticated and req.method == 'POST' \
+                and handler is not self:
+            return Intercept(handler)
+        return handler
+
+    def post_process_request(self, req, template, content_type):
+        return (template, content_type)

Property changes on: tracspamfilter/captcha/api.py
___________________________________________________________________
Name: svn:eol-style
   + native

Index: tracspamfilter/captcha/__init__.py
===================================================================
--- tracspamfilter/captcha/__init__.py	(revision 0)
+++ tracspamfilter/captcha/__init__.py	(revision 0)
@@ -0,0 +1 @@
+from tracspamfilter.captcha.api import *

Property changes on: tracspamfilter/captcha/__init__.py
___________________________________________________________________
Name: svn:eol-style
   + native

Index: tracspamfilter/captcha/expression.py
===================================================================
--- tracspamfilter/captcha/expression.py	(revision 0)
+++ tracspamfilter/captcha/expression.py	(revision 0)
@@ -0,0 +1,46 @@
+import random
+from trac.core import *
+from trac.config import *
+from trac.util.html import html, Markup
+from tracspamfilter.captcha import ICaptchaMethod
+
+
+class ExpressionCaptcha(Component):
+    """ Implementation of a captcha in the form of a human readable numeric
+    expression. Initial implementation by sergeych@tancher.com. """
+
+    implements(ICaptchaMethod)
+
+    terms = IntOption('numeric-captcha', 'terms', 3,
+            """ Number of terms in expression. """)
+    ceiling = IntOption('numeric-captcha', 'ceiling', 10,
+            """ Maximum value of individual terms in expression. """)
+
+    operations = {'*': 'multiplied by', '-': 'minus', '+': 'plus'}
+    numerals = ('zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven',
+                'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen',
+                'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen',
+                'nineteen' )
+    tens = ('twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy',
+            'eighty', 'ninety')
+
+    # ICaptchaMethod methods
+    def generate_captcha(self, req):
+        if self.ceiling > 100:
+            raise TracError('Numeric captcha can not represent numbers > 100')
+        terms = [str(random.randrange(0, self.ceiling)) for _ in xrange(self.terms)]
+        operations = [random.choice(self.operations.keys()) for _ in xrange(self.terms)]
+        expression = sum(zip(terms, operations), ())[:-1]
+        expression = eval(compile(' '.join(expression), 'captcha_eval', 'eval'))
+        human = sum(zip([self.humanise(int(t)) for t in terms],
+                        [self.operations[o] for o in operations]), ())[:-1]
+        return (expression, html.blockquote(' '.join(map(str, human))))
+
+    # Internal methods
+    def humanise(self, value):
+        if value < 20:
+            return self.numerals[value]
+        english = self.tens[value / 10 - 2]
+        if value % 10:
+            english += ' ' + self.numerals[value % 10]
+        return english

Property changes on: tracspamfilter/captcha/expression.py
___________________________________________________________________
Name: svn:eol-style
   + native

Index: tracspamfilter/captcha/image.py
===================================================================
--- tracspamfilter/captcha/image.py	(revision 0)
+++ tracspamfilter/captcha/image.py	(revision 0)
@@ -0,0 +1,78 @@
+import os
+import random
+import Image
+import ImageFont
+import ImageDraw
+import ImageFilter
+from StringIO import StringIO
+from trac.core import *
+from trac.util.html import html
+from trac.config import *
+from trac.web.api import IRequestHandler
+from tracspamfilter.captcha import ICaptchaMethod
+
+
+class ImageCaptcha(Component):
+    """ An image captcha courtesy of
+    http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/440588 """
+
+    implements(ICaptchaMethod)
+    implements(IRequestHandler)
+
+    fonts = ListOption('image-captcha', 'fonts', 'vera.ttf',
+        doc=""" Set of fonts to choose from. """)
+    font_size = IntOption('image-captcha', 'font_size', 25,
+        "Font size")
+    alphabet = Option('image-captcha', 'alphabet', 'abcdefghkmnopqrstuvwxyz',
+        """ Alphabet to choose captcha challenge from. """)
+    letters = IntOption('image-captcha', 'letters', 6,
+        """ Number of letters to use in challenge. """)
+
+    # IRequestHandler methods
+    def match_request(self, req):
+        return req.path_info == '/captcha/image'
+
+    def process_request(self, req):
+        if 'captcha_expected' not in req.session:
+            # TODO Probably need to render an error image here
+            raise TracError('No Captcha response in session')
+        req.send_response(200)
+        req.send_header('Content-Type', 'image/jpeg')
+        req.end_headers()
+
+        image = StringIO()
+        from pkg_resources import resource_filename
+        font = os.path.join(resource_filename('tracspamfilter', 'fonts'),
+                            random.choice(self.fonts))
+        self.gen_captcha(image, req.session['captcha_expected'], font,
+                         self.font_size)
+        req.write(image.getvalue())
+
+    # ICaptchaMethod methods
+    def generate_captcha(self, req):
+        challenge = ''.join([random.choice(self.alphabet) for _ in xrange(self.letters)])
+        return challenge, html.blockquote(html.img(src=req.href('/captcha/image')))
+
+    # Internal methods
+    def gen_captcha(self, file, text, fnt, fnt_sz, fmt='JPEG'):
+        # randomly select the foreground color
+        fgcolor = random.randint(0,0xffff00)
+        # make the background color the opposite of fgcolor
+        bgcolor = fgcolor ^ 0xffffff
+        # create a font object 
+        font = ImageFont.truetype(fnt,fnt_sz)
+        # determine dimensions of the text
+        dim = font.getsize(text)
+        # create a new image slightly larger that the text
+        im = Image.new('RGB', (dim[0]+5,dim[1]+5), bgcolor)
+        d = ImageDraw.Draw(im)
+        x, y = im.size
+        r = random.randint
+        # draw 100 random colored boxes on the background
+        for num in range(100):
+            d.rectangle((r(0,x),r(0,y),r(0,x),r(0,y)),fill=r(0,0xffffff))
+        # add the text to the image
+        d.text((3,3), text, font=font, fill=fgcolor)
+        im = im.filter(ImageFilter.EDGE_ENHANCE_MORE)
+        # save the image to a file
+        im.save(file, format=fmt)

Property changes on: tracspamfilter/captcha/image.py
___________________________________________________________________
Name: svn:eol-style
   + native

Index: tracspamfilter/captcha/captchasdotnet.py
===================================================================
--- tracspamfilter/captcha/captchasdotnet.py	(revision 0)
+++ tracspamfilter/captcha/captchasdotnet.py	(revision 0)
@@ -0,0 +1,277 @@
+import os
+import md5
+import random
+import time
+from trac.core import *
+from trac.config import *
+from trac.util.html import html
+from tracspamfilter.captcha import ICaptchaMethod
+
+
+class CaptchasDotNetCaptcha(Component):
+    """ Trac adapter for the captcha.net Captcha community site. `client` and
+    `secret` '''must''' be configured in the `captchas.net` section of
+    `trac.ini`. """
+    implements(ICaptchaMethod)
+
+    client = Option('captchas.net', 'client', doc=
+        """ [http://captchas.net] registered client name ('''required''') """)
+    secret = Option('captchas.net', 'secret', doc=
+        """ [http://captchas.net] registered client secret ('''required''') """)
+    alphabet = Option('captchas.net', 'alphabet', 'abcdefghkmnopqrstuvwxyz',
+        """ Alphabet to choose captcha challenge from. """)
+    letters = IntOption('captchas.net', 'letters', 6,
+        """ Number of letters to use in challenge. """)
+    width = IntOption('captchas.net', 'width', 240,
+        """ Width of captcha. """)
+    height = IntOption('captchas.net', 'height', 80,
+        """ Height of captcha. """)
+    random_repository = Option('captchas.net', 'random_repository',
+                               '/tmp/captchasnet-random-strings',
+        """ Local captcha cache directory. """)
+    cleanup_time = IntOption('captchas.net', 'cleanup_time', 3600,
+        """ Delay, in seconds, before old captcha images are removed. """)
+
+    # ICaptchaMethod methods
+    def generate_captcha(self, req):
+        if not self.client or not self.secret:
+            raise TracError('captchas.net plugin not configured (`[captchas.net] client = xxx secret = yyy)')
+        captcha = CaptchasDotNet(self.client, self.secret, self.alphabet,
+                                 self.letters, self.width, self.height,
+                                 self.random_repository, self.cleanup_time)
+        challenge = captcha.random()
+        html = html.p('Please respond with the letters in the following image or sound:')(
+            Markup(captchas.image()),
+            html.a('Phonetic spelling', href=captchas.audio_url())
+        )
+        return (challenge, html)
+
+
+#---------------------------------------------------------------------       
+# Python module for easy utilization of http://captchas.net
+#
+# For documentation look at http://captchas.net/sample/python/
+# 
+# Written by Sebastian Wilhelmi <seppi@seppi.de> and
+#            Felix Holderied <felix@holderied.de>
+# This file is in the public domain.
+#
+# ChangeLog:
+#
+# 2006-09-08: Add new optional parameters alphabet, letters 
+#             height an width. Add audio_url. 
+#      
+# 2006-03-01: Only delete the random string from the repository in
+#             case of a successful verification.
+#
+# 2006-02-14: Add new image() method returning an HTML/JavaScript
+#             snippet providing a fault tolerant service.
+#
+# 2005-06-02: Initial version.
+#
+#---------------------------------------------------------------------
+
+class CaptchasDotNet:
+    def __init__ (self, client, secret,
+                  alphabet = 'abcdefghkmnopqrstuvwxyz',
+                  letters = 6,
+                  width = 240,
+                  height = 80,
+                  random_repository = '/tmp/captchasnet-random-strings',
+                  cleanup_time = 3600
+                  ):
+        self.__client = client
+        self.__secret = secret
+        self.__alphabet = alphabet
+        self.__letters = letters
+        self.__width = width
+        self.__height = height
+        self.__random_repository = random_repository
+        self.__cleanup_time = cleanup_time
+        self.__time_stamp_file = os.path.join (random_repository,
+                                               '__time_stamp__')
+
+    # Return a random string
+    def __random_string (self):
+        # The random string shall consist of small letters, big letters
+        # and digits.
+        letters = "abcdefghijklmnopqrstuvwxyz"
+        letters += letters.upper () + "0123456789"
+
+        # The random starts out empty, then 40 random possible characters
+        # are appended.
+        random_string = ''
+        for i in range (40):
+            random_string += random.choice (letters)
+
+        # Return the random string.
+        return random_string
+
+    # Create a new random string and register it.
+    def random (self):
+        # If the repository directory is does not yet exist, create it.
+        if not os.path.isdir (self.__random_repository):
+            os.makedirs (self.__random_repository)
+            
+        # If the time stamp file does not yet exist, create it.
+        if not os.path.isfile (self.__time_stamp_file):
+            os.close (os.open (self.__time_stamp_file, os.O_CREAT, 0700))
+
+        # Get the current time.
+        now = time.time ()
+
+        # Determine the time, before which to remove random strings.
+        cleanup_time = now - self.__cleanup_time
+
+        # If the last cleanup is older than specified, cleanup the
+        # directory.
+        if os.stat (self.__time_stamp_file).st_mtime < cleanup_time:
+            os.utime (self.__time_stamp_file, (now, now))
+            for file_name in os.listdir (self.__random_repository):
+                file_name = os.path.join (self.__random_repository, file_name)
+                if os.stat (file_name).st_mtime < cleanup_time:
+                    os.unlink (file_name)
+
+        # loop until a valid random string has been found and registered.
+        while True:
+            # generate a new random string.
+            random = self.__random_string ()
+
+            # open a file with the corresponding name in the repository
+            # directory in such a way, that the creation fails, when the
+            # file already exists. That should be near to impossible with
+            # good seeding of the random number generator, but it's better
+            # to play safe.
+            try:
+                os.close (os.open (os.path.join (self.__random_repository,
+                                                 random),
+                                   os.O_EXCL | os.O_CREAT, 0700))
+            except EnvironmentError, error:
+                # if the file already existed, rerun the loop to try the
+                # next string.
+                if error.errno == errno.EEXIST:
+                    continue
+                else:
+                    # other errors will certainly persist for other random
+                    # strings, so raise the exception.
+                    raise
+
+            # return the successfully registered random string.
+            self.__random = random
+            return random
+
+    def image_url (self, random = None, base = 'http://image.captchas.net/'):
+        if not random:
+            random = self.__random
+        url = base
+        url += '?client=%s&amp;random=%s' % (self.__client, random)
+        if self.__alphabet != "abcdefghijklmnopqrstuvwxyz":
+            url += '&amp;alphabet=%s' % self.__alphabet
+        if self.__letters != 6:
+            url += '&amp;letters=%s' % self.__letters
+        if self.__width != 240:
+            url += '&amp;width=%s' % self.__width
+        if self.__height != 80:
+            url += '&amp;height=%s' % self.__height
+        return url
+
+    def audio_url (self, random = None, base = 'http://audio.captchas.net/'):
+        if not random:
+            random = self.__random
+        url = base
+        url += '?client=%s&amp;random=%s' % (self.__client, random)
+        if self.__alphabet != "abcdefghijklmnopqrstuvwxyz":
+            url += '&amp;alphabet=%s' % self.__alphabet
+        if self.__letters != 6:
+            url += '&amp;letters=%s' % self.__letters
+        return url
+
+    def image (self, random = None, id = 'captchas.net'):
+        return '''
+        <a href="http://captchas.net"><img
+            style="border: none; vertical-align: bottom"
+            id="%s" src="%s" width="%d" height="%d"
+            alt="The CAPTCHA image" /></a>
+        <script type="text/javascript">
+          <!--
+          function captchas_image_error (image) 
+          {
+            if (!image.timeout) return true;
+            image.src = image.src.replace (/^http:\/\/image\.captchas\.net/, 
+                                           'http://image.backup.captchas.net');
+            return captchas_image_loaded (image);
+          }
+
+          function captchas_image_loaded (image)
+          {
+            if (!image.timeout) return true;
+            window.clearTimeout (image.timeout);
+            image.timeout = false;
+            return true;
+          }
+
+          var image = document.getElementById ('%s');
+          image.onerror = function() {return captchas_image_error (image);};
+          image.onload = function() {return captchas_image_loaded (image);};
+          image.timeout 
+            = window.setTimeout(
+               "captchas_image_error (document.getElementById ('%s'))",
+               10000);
+          image.src = image.src;
+          //-->      
+        </script>''' % (id, self.image_url (random), self.__width, self.__height, id, id)
+
+    def validate (self, random):
+        self.__random = random
+
+        file_name = os.path.join (self.__random_repository, random)
+
+        # Find out, whether the file exists
+        result = os.path.isfile (file_name)
+
+        # if the file exists, remember it.
+        if result:
+            self.__random_file = file_name
+
+        # the random string was valid, if and only if the
+        # corresponding file existed.
+        return result
+        
+    def verify (self, input, random = None):
+        if not random:
+            random = self.__random
+            
+        # The format of the password.
+        password_alphabet = self.__alphabet
+        password_length = self.__letters
+
+        # If the user input has the wrong lenght, it can't be correct.
+        if len (input) != password_length:
+            return False
+
+        # Calculate the MD5 digest of the concatenation of secret key and
+        # random string.
+        encryption_base = self.__secret + random
+        if (password_alphabet != "abcdefghijklmnopqrstuvwxyz") or (password_length != 6):
+            encryption_base += ":" + password_alphabet + ":" + str(password_length)
+        digest = md5.new (encryption_base).digest ()
+
+        # Compute password
+        correct_password = ''
+        for pos in range (password_length):
+            letter_num = ord (digest[pos]) % len (password_alphabet)
+            correct_password += password_alphabet[letter_num]
+ 
+        # Check password
+        if input != correct_password:
+            return False
+
+        # Remove the correspondig random file, if it exists.
+        try:
+            os.unlink (self.__random_file)
+            del self.__random_file
+        except:
+            pass
+        
+        # The user input was correct.
+        return True

Property changes on: tracspamfilter/captcha/captchasdotnet.py
___________________________________________________________________
Name: svn:eol-style
   + native

Index: tracspamfilter/fonts/vera.ttf
===================================================================
Cannot display: file marked as a binary type.
svn:mime-type = application/octet-stream

Property changes on: tracspamfilter/fonts/vera.ttf
___________________________________________________________________
Name: svn:mime-type
   + application/octet-stream

Index: tracspamfilter/templates/verify_captcha.cs
===================================================================
--- tracspamfilter/templates/verify_captcha.cs	(revision 0)
+++ tracspamfilter/templates/verify_captcha.cs	(revision 0)
@@ -0,0 +1,23 @@
+<?cs include "header.cs" ?>
+<?cs include "macros.cs" ?>
+
+<?cs if:error ?>
+<div id="content" class="error">
+ <h1>Captcha Error</h1>
+ <p class="message"><?cs var:error ?></p>
+<?cs else ?>
+<div id="content" class="traccaptcha">
+<?cs /if ?>
+<form method="post" action="<?cs var:captcha.href ?>">
+<p>
+Trac thinks your submission might be Spam. To prove otherwise please provide a response to the following.
+</p>
+<p>
+<?cs var:captcha.challenge ?>
+</p>
+Response: <input type="text" name="captcha_response"/>
+<input type="submit" value="Submit"/>
+</form>
+</div>
+
+<?cs include "footer.cs" ?>
Index: setup.py
===================================================================
--- setup.py	(revision 4114)
+++ setup.py	(working copy)
@@ -31,6 +31,7 @@
     extras_require = {
         'DNS': ['dnspython>=1.3.5'],
         'SpamBayes': ['spambayes'],
+        'PIL': ['pil'],
     },
     entry_points = """
         [trac.plugins]
@@ -44,6 +45,10 @@
         spamfilter.ip_throttle = tracspamfilter.filters.ip_throttle
         spamfilter.regex = tracspamfilter.filters.regex
         spamfilter.session = tracspamfilter.filters.session
+        spamfilter.captcha = tracspamfilter.captcha.api
+        spamfilter.captcha.image = tracspamfilter.captcha.image[PIL]
+        spamfilter.captcha.expression = tracspamfilter.captcha.expression
+        spamfilter.captcha.captchasdotnet = tracspamfilter.captcha.captchasdotnet
     """,
     test_suite = 'tracspamfilter.tests.suite',
     zip_safe = True

