[#5384] Make spam-checking provider pluggable and add Mollom impl.
diff --git a/Allura/allura/lib/app_globals.py b/Allura/allura/lib/app_globals.py
index 09f5bd9..862a03c 100644
--- a/Allura/allura/lib/app_globals.py
+++ b/Allura/allura/lib/app_globals.py
@@ -161,6 +161,7 @@
registration=_cache_eps('allura.project_registration'),
theme=_cache_eps('allura.theme'),
user_prefs=_cache_eps('allura.user_prefs'),
+ spam=_cache_eps('allura.spam'),
)
# Zarkov logger
@@ -168,21 +169,10 @@
@LazyProperty
def spam_checker(self):
- """Return an Akismet spam checker if config defines an Akismet API key.
- Otherwise, return a no-op spam checker.
-
- Eventually we may support checkers for other services like Mollom and
- Defensio.
+ """Return a SpamFilter implementation.
"""
- akismet_key = config.get('spam.akismet_key')
- if akismet_key:
- from allura.lib.spam import akismetservice
- checker = akismetservice.Akismet(akismet_key, config.get('base_url'))
- checker.verify_key()
- else:
- from allura.lib import spam
- checker = spam.FakeSpamChecker()
- return checker
+ from allura.lib import spam
+ return spam.SpamFilter.get(config, self.entry_points['spam'])
@LazyProperty
def director(self):
diff --git a/Allura/allura/lib/spam/__init__.py b/Allura/allura/lib/spam/__init__.py
index 954cda1..5c5841d 100644
--- a/Allura/allura/lib/spam/__init__.py
+++ b/Allura/allura/lib/spam/__init__.py
@@ -1,4 +1,25 @@
-class FakeSpamChecker(object):
- """No-op spam checker"""
- def check(self, *args, **kw):
+import logging
+
+
+log = logging.getLogger(__name__)
+
+
+class SpamFilter(object):
+ """Defines the spam checker interface and provides a default no-op impl."""
+ def __init__(self, config):
+ pass
+
+ def check(self, text, artifact=None, user=None, content_type='comment', **kw):
+ """Return True if ``text`` is spam, else False."""
+ log.info("No spam checking enabled")
return False
+
+ @classmethod
+ def get(cls, config, entry_points):
+ """Return an instance of the SpamFilter impl specified in ``config``.
+ """
+ method = config.get('spam.method')
+ if not method:
+ return cls(config)
+ result = entry_points[method]
+ return result(config)
diff --git a/Allura/allura/lib/spam/akismetservice.py b/Allura/allura/lib/spam/akismetfilter.py
similarity index 61%
rename from Allura/allura/lib/spam/akismetservice.py
rename to Allura/allura/lib/spam/akismetfilter.py
index af5ece7..6af659b 100644
--- a/Allura/allura/lib/spam/akismetservice.py
+++ b/Allura/allura/lib/spam/akismetfilter.py
@@ -4,12 +4,32 @@
from pylons import tmpl_context as c
from allura.lib import helpers as h
+from allura.lib.spam import SpamFilter
import akismet
+
log = logging.getLogger(__name__)
-class Akismet(akismet.Akismet):
+
+class AkismetSpamFilter(SpamFilter):
+ """Spam checking implementation via Akismet service.
+
+ To enable Akismet spam filtering in your Allura instance, first
+ enable the entry point in setup.py::
+
+ [allura.spam]
+ akismet = allura.lib.spam.akismetfilter:AkismetSpamFilter
+
+ Then include the following parameters in your .ini file::
+
+ spam.method = akismet
+ spam.key = <your Akismet key here>
+ """
+ def __init__(self, config):
+ self.service = akismet.Akismet(config.get('spam.key'), config.get('base_url'))
+ self.service.verify_key()
+
def check(self, text, artifact=None, user=None, content_type='comment', **kw):
log_msg = text
kw['comment_content'] = text
@@ -28,6 +48,6 @@
# kw will be urlencoded, need to utf8-encode
for k, v in kw.items():
kw[k] = h.really_unicode(v).encode('utf8')
- res = self.comment_check(text, data=kw, build_data=False)
+ res = self.service.comment_check(text, data=kw, build_data=False)
log.info("spam=%s (akismet): %s" % (str(res), log_msg))
return res
diff --git a/Allura/allura/lib/spam/mollomfilter.py b/Allura/allura/lib/spam/mollomfilter.py
new file mode 100644
index 0000000..c8356bb
--- /dev/null
+++ b/Allura/allura/lib/spam/mollomfilter.py
@@ -0,0 +1,61 @@
+import logging
+
+from pylons import request
+from pylons import tmpl_context as c
+
+from allura.lib import helpers as h
+from allura.lib.spam import SpamFilter
+
+import Mollom
+
+
+log = logging.getLogger(__name__)
+
+
+class MollomSpamFilter(SpamFilter):
+ """Spam checking implementation via Mollom service.
+
+ To enable Mollom spam filtering in your Allura instance, first
+ enable the entry point in setup.py::
+
+ [allura.spam]
+ mollom = allura.lib.spam.mollomfilter:MollomSpamFilter
+
+ Then include the following parameters in your .ini file::
+
+ spam.method = mollom
+ spam.public_key = <your Mollom public key here>
+ spam.private_key = <your Mollom private key here>
+ """
+ def __init__(self, config):
+ self.service = Mollom.MollomAPI(
+ publicKey=config.get('spam.public_key'),
+ privateKey=config.get('spam.private_key'))
+ if not self.service.verifyKey():
+ raise Mollom.MollomFault('Your MOLLOM credentials are invalid.')
+
+ def check(self, text, artifact=None, user=None, content_type='comment', **kw):
+ """Basic content spam check via Mollom. For more options
+ see http://mollom.com/api#api-content
+ """
+ log_msg = text
+ kw['postBody'] = text
+ if artifact:
+ # Should be able to send url, but can't right now due to a bug in
+ # the PyMollom lib
+ # kw['url'] = artifact.url()
+ log_msg = artifact.url()
+ user = user or c.user
+ if user:
+ kw['authorName'] = user.display_name or user.username
+ kw['authorMail'] = user.email_addresses[0] if user.email_addresses else ''
+ user_ip = request.headers.get('X_FORWARDED_FOR', request.remote_addr)
+ kw['authorIP'] = user_ip.split(',')[0].strip()
+ # kw will be urlencoded, need to utf8-encode
+ for k, v in kw.items():
+ kw[k] = h.really_unicode(v).encode('utf8')
+ cc = self.service.checkContent(**kw)
+ res = cc['spam'] == 2
+ log.info("spam=%s (mollom): %s" % (str(res), log_msg))
+ return res
+
diff --git a/Allura/allura/tests/unit/test_spam.py b/Allura/allura/tests/unit/spam/test_akismet.py
similarity index 66%
rename from Allura/allura/tests/unit/test_spam.py
rename to Allura/allura/tests/unit/spam/test_akismet.py
index e364783..aeb4afe 100644
--- a/Allura/allura/tests/unit/test_spam.py
+++ b/Allura/allura/tests/unit/spam/test_akismet.py
@@ -5,20 +5,21 @@
import urllib
try:
- from allura.lib.spam.akismetservice import Akismet
+ from allura.lib.spam.akismetfilter import AkismetSpamFilter
except ImportError:
- Akismet = None
+ AkismetSpamFilter = None
-@unittest.skipIf(Akismet is None, "Can't import Akismet")
+@unittest.skipIf(AkismetSpamFilter is None, "Can't import AkismetSpamFilter")
class TestAkismet(unittest.TestCase):
- def setUp(self):
- self.akismet = Akismet()
+ @mock.patch('allura.lib.spam.akismetfilter.akismet')
+ def setUp(self, akismet_lib):
+ self.akismet = AkismetSpamFilter({})
def side_effect(*args, **kw):
# side effect to test that data being sent to
# akismet can be successfully urlencoded
urllib.urlencode(kw.get('data', {}))
- self.akismet.comment_check = mock.Mock(side_effect=side_effect)
+ self.akismet.service.comment_check = mock.Mock(side_effect=side_effect)
self.fake_artifact = mock.Mock(**{'url.return_value': 'artifact url'})
self.fake_user = mock.Mock(display_name=u'Søme User',
email_addresses=['user@domain'])
@@ -35,38 +36,38 @@
user_agent='some browser',
referrer='some url')
- @mock.patch('allura.lib.spam.akismetservice.c')
- @mock.patch('allura.lib.spam.akismetservice.request')
+ @mock.patch('allura.lib.spam.akismetfilter.c')
+ @mock.patch('allura.lib.spam.akismetfilter.request')
def test_check(self, request, c):
request.headers = self.fake_headers
c.user = None
self.akismet.check(self.content)
- self.akismet.comment_check.assert_called_once_with(self.content,
+ self.akismet.service.comment_check.assert_called_once_with(self.content,
data=self.expected_data, build_data=False)
- @mock.patch('allura.lib.spam.akismetservice.c')
- @mock.patch('allura.lib.spam.akismetservice.request')
+ @mock.patch('allura.lib.spam.akismetfilter.c')
+ @mock.patch('allura.lib.spam.akismetfilter.request')
def test_check_with_explicit_content_type(self, request, c):
request.headers = self.fake_headers
c.user = None
self.akismet.check(self.content, content_type='some content type')
self.expected_data['comment_type'] = 'some content type'
- self.akismet.comment_check.assert_called_once_with(self.content,
+ self.akismet.service.comment_check.assert_called_once_with(self.content,
data=self.expected_data, build_data=False)
- @mock.patch('allura.lib.spam.akismetservice.c')
- @mock.patch('allura.lib.spam.akismetservice.request')
+ @mock.patch('allura.lib.spam.akismetfilter.c')
+ @mock.patch('allura.lib.spam.akismetfilter.request')
def test_check_with_artifact(self, request, c):
request.headers = self.fake_headers
c.user = None
self.akismet.check(self.content, artifact=self.fake_artifact)
expected_data = self.expected_data
expected_data['permalink'] = 'artifact url'
- self.akismet.comment_check.assert_called_once_with(self.content,
+ self.akismet.service.comment_check.assert_called_once_with(self.content,
data=expected_data, build_data=False)
- @mock.patch('allura.lib.spam.akismetservice.c')
- @mock.patch('allura.lib.spam.akismetservice.request')
+ @mock.patch('allura.lib.spam.akismetfilter.c')
+ @mock.patch('allura.lib.spam.akismetfilter.request')
def test_check_with_user(self, request, c):
request.headers = self.fake_headers
c.user = None
@@ -74,11 +75,11 @@
expected_data = self.expected_data
expected_data.update(comment_author=u'Søme User'.encode('utf8'),
comment_author_email='user@domain')
- self.akismet.comment_check.assert_called_once_with(self.content,
+ self.akismet.service.comment_check.assert_called_once_with(self.content,
data=expected_data, build_data=False)
- @mock.patch('allura.lib.spam.akismetservice.c')
- @mock.patch('allura.lib.spam.akismetservice.request')
+ @mock.patch('allura.lib.spam.akismetfilter.c')
+ @mock.patch('allura.lib.spam.akismetfilter.request')
def test_check_with_implicit_user(self, request, c):
request.headers = self.fake_headers
c.user = self.fake_user
@@ -86,11 +87,11 @@
expected_data = self.expected_data
expected_data.update(comment_author=u'Søme User'.encode('utf8'),
comment_author_email='user@domain')
- self.akismet.comment_check.assert_called_once_with(self.content,
+ self.akismet.service.comment_check.assert_called_once_with(self.content,
data=expected_data, build_data=False)
- @mock.patch('allura.lib.spam.akismetservice.c')
- @mock.patch('allura.lib.spam.akismetservice.request')
+ @mock.patch('allura.lib.spam.akismetfilter.c')
+ @mock.patch('allura.lib.spam.akismetfilter.request')
def test_check_with_fallback_ip(self, request, c):
self.expected_data['user_ip'] = 'fallback ip'
self.fake_headers.pop('X_FORWARDED_FOR')
@@ -98,5 +99,5 @@
request.remote_addr = self.fake_headers['REMOTE_ADDR']
c.user = None
self.akismet.check(self.content)
- self.akismet.comment_check.assert_called_once_with(self.content,
+ self.akismet.service.comment_check.assert_called_once_with(self.content,
data=self.expected_data, build_data=False)
diff --git a/Allura/allura/tests/unit/spam/test_mollom.py b/Allura/allura/tests/unit/spam/test_mollom.py
new file mode 100644
index 0000000..e2a0390
--- /dev/null
+++ b/Allura/allura/tests/unit/spam/test_mollom.py
@@ -0,0 +1,77 @@
+# -*- coding: utf-8 -*-
+
+import mock
+import unittest
+import urllib
+
+try:
+ from allura.lib.spam.mollomfilter import MollomSpamFilter
+except ImportError:
+ MollomSpamFilter = None
+
+
+@unittest.skipIf(MollomSpamFilter is None, "Can't import MollomSpamFilter")
+class TestMollom(unittest.TestCase):
+ @mock.patch('allura.lib.spam.mollomfilter.Mollom')
+ def setUp(self, mollom_lib):
+ self.mollom = MollomSpamFilter({})
+ def side_effect(*args, **kw):
+ # side effect to test that data being sent to
+ # mollom can be successfully urlencoded
+ urllib.urlencode(kw.get('data', {}))
+ return dict(spam=2)
+ self.mollom.service.checkContent = mock.Mock(side_effect=side_effect,
+ return_value=dict(spam=2))
+ self.fake_artifact = mock.Mock(**{'url.return_value': 'artifact url'})
+ self.fake_user = mock.Mock(display_name=u'Søme User',
+ email_addresses=['user@domain'])
+ self.fake_headers = dict(
+ REMOTE_ADDR='fallback ip',
+ X_FORWARDED_FOR='some ip',
+ USER_AGENT='some browser',
+ REFERER='some url')
+ self.content = u'spåm text'
+ self.expected_data = dict(
+ postBody=self.content.encode('utf8'),
+ authorIP='some ip')
+
+ @mock.patch('allura.lib.spam.mollomfilter.c')
+ @mock.patch('allura.lib.spam.mollomfilter.request')
+ def test_check(self, request, c):
+ request.headers = self.fake_headers
+ c.user = None
+ self.mollom.check(self.content)
+ self.mollom.service.checkContent.assert_called_once_with(**self.expected_data)
+
+ @mock.patch('allura.lib.spam.mollomfilter.c')
+ @mock.patch('allura.lib.spam.mollomfilter.request')
+ def test_check_with_user(self, request, c):
+ request.headers = self.fake_headers
+ c.user = None
+ self.mollom.check(self.content, user=self.fake_user)
+ expected_data = self.expected_data
+ expected_data.update(authorName=u'Søme User'.encode('utf8'),
+ authorMail='user@domain')
+ self.mollom.service.checkContent.assert_called_once_with(**self.expected_data)
+
+ @mock.patch('allura.lib.spam.mollomfilter.c')
+ @mock.patch('allura.lib.spam.mollomfilter.request')
+ def test_check_with_implicit_user(self, request, c):
+ request.headers = self.fake_headers
+ c.user = self.fake_user
+ self.mollom.check(self.content)
+ expected_data = self.expected_data
+ expected_data.update(authorName=u'Søme User'.encode('utf8'),
+ authorMail='user@domain')
+ self.mollom.service.checkContent.assert_called_once_with(**self.expected_data)
+
+ @mock.patch('allura.lib.spam.mollomfilter.c')
+ @mock.patch('allura.lib.spam.mollomfilter.request')
+ def test_check_with_fallback_ip(self, request, c):
+ self.expected_data['authorIP'] = 'fallback ip'
+ self.fake_headers.pop('X_FORWARDED_FOR')
+ request.headers = self.fake_headers
+ request.remote_addr = self.fake_headers['REMOTE_ADDR']
+ c.user = None
+ self.mollom.check(self.content)
+ self.mollom.service.checkContent.assert_called_once_with(**self.expected_data)
diff --git a/Allura/allura/tests/unit/spam/test_spam_filter.py b/Allura/allura/tests/unit/spam/test_spam_filter.py
new file mode 100644
index 0000000..990cb70
--- /dev/null
+++ b/Allura/allura/tests/unit/spam/test_spam_filter.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+
+import mock
+import unittest
+import urllib
+
+from allura.lib.spam import SpamFilter
+
+
+class TestSpamFilter(unittest.TestCase):
+ def test_check(self):
+ # default no-op impl always returns False
+ self.assertFalse(SpamFilter({}).check('foo'))
+
+ def test_get_default(self):
+ config = {}
+ entry_points = None
+ checker = SpamFilter.get(config, entry_points)
+ self.assertTrue(isinstance(checker, SpamFilter))
+
+ def test_get_method(self):
+ config = {'spam.method': 'mock'}
+ entry_points = {'mock': mock.Mock}
+ checker = SpamFilter.get(config, entry_points)
+ self.assertTrue(isinstance(checker, mock.Mock))
+
diff --git a/Allura/setup.py b/Allura/setup.py
index cdc6a06..c1a10d6 100644
--- a/Allura/setup.py
+++ b/Allura/setup.py
@@ -104,6 +104,10 @@
[allura.theme]
allura = allura.lib.plugin:ThemeProvider
+ [allura.spam]
+ #akismet = allura.lib.spam.akismetfilter:AkismetSpamFilter
+ #mollom = allura.lib.spam.mollomfilter:MollomSpamFilter
+
[paste.paster_command]
taskd = allura.command.taskd:TaskdCommand
taskd_cleanup = allura.command.taskd_cleanup:TaskdCleanupCommand