purge comments in moderation queue after given time, closes #13

This commit is contained in:
Martin Zimmermann 2013-10-24 14:16:14 +02:00
parent a8d0670db7
commit 48b4c9f9a5
9 changed files with 207 additions and 49 deletions

View File

@ -39,8 +39,7 @@ establish a connection to your website (a simple HEAD request). If this
check fails, none can comment on your website. check fails, none can comment on your website.
max-age max-age
: time to allow users to remove or edit their comments. Defaults to `900` : time to allow users to remove or edit their comments. Defaults to `15m`.
seconds (15 minutes).
## server (not applicable for uWSGI) ## server (not applicable for uWSGI)
@ -54,6 +53,14 @@ reload
: reload application, when editing the source code (only useful for developers), : reload application, when editing the source code (only useful for developers),
disabled by default. disabled by default.
## moderation
enabled
: enable comment moderation queue, disabled by default
purge-after
: remove unprocessed comments in moderation queue after, by default, `30d`.
## SMTP ## SMTP
username username

View File

@ -32,10 +32,18 @@ dist = pkg_resources.get_distribution("isso")
import sys import sys
import os import os
import socket
from os.path import dirname, join from os.path import dirname, join
from argparse import ArgumentParser from argparse import ArgumentParser
try:
import httplib
import urlparse
except ImportError:
import http.client as httplib
import urllib.parse as urlparse
import misaka import misaka
from itsdangerous import URLSafeTimedSerializer from itsdangerous import URLSafeTimedSerializer
@ -49,8 +57,8 @@ from werkzeug.contrib.fixers import ProxyFix
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from isso import db, migrate, views, wsgi from isso import db, migrate, views, wsgi, colors
from isso.core import NaiveMixin, uWSGIMixin, Config from isso.core import ThreadedMixin, uWSGIMixin, Config
from isso.views import comment, admin from isso.views import comment, admin
rules = Map([ rules = Map([
@ -76,13 +84,13 @@ class Isso(object):
def __init__(self, conf): def __init__(self, conf):
super(Isso, self).__init__(conf)
self.conf = conf self.conf = conf
self.db = db.SQLite3(conf.get('general', 'dbpath'), conf) self.db = db.SQLite3(conf.get('general', 'dbpath'), conf)
self.signer = URLSafeTimedSerializer(conf.get('general', 'secretkey')) self.signer = URLSafeTimedSerializer(conf.get('general', 'secretkey'))
self.j2env = Environment(loader=FileSystemLoader(join(dirname(__file__), 'templates/'))) self.j2env = Environment(loader=FileSystemLoader(join(dirname(__file__), 'templates/')))
super(Isso, self).__init__(conf)
def sign(self, obj): def sign(self, obj):
return self.signer.dumps(obj) return self.signer.dumps(obj)
@ -128,7 +136,7 @@ def make_app(conf=None):
try: try:
import uwsgi import uwsgi
except ImportError: except ImportError:
class App(Isso, NaiveMixin): class App(Isso, ThreadedMixin):
pass pass
else: else:
class App(Isso, uWSGIMixin): class App(Isso, uWSGIMixin):
@ -136,6 +144,18 @@ def make_app(conf=None):
isso = App(conf) isso = App(conf)
if not conf.get("general", "host").startswith(("http://", "https://")):
raise SystemExit("error: host must start with http:// or https://")
try:
print(" * connecting to HTTP server", end=" ")
rv = urlparse.urlparse(conf.get("general", "host"))
host = (rv.netloc + ':443') if rv.scheme == 'https' else rv.netloc
httplib.HTTPConnection(host, timeout=5).request('GET', rv.path)
print("[%s]" % colors.green("ok"))
except (httplib.HTTPException, socket.error):
print("[%s]" % colors.red("failed"))
app = ProxyFix(wsgi.SubURI(SharedDataMiddleware(isso.wsgi_app, { app = ProxyFix(wsgi.SubURI(SharedDataMiddleware(isso.wsgi_app, {
'/static': join(dirname(__file__), 'static/'), '/static': join(dirname(__file__), 'static/'),
'/js': join(dirname(__file__), 'js/'), '/js': join(dirname(__file__), 'js/'),

View File

@ -22,16 +22,29 @@ from isso.compat import PY2K
if PY2K: if PY2K:
import thread import thread
import httplib
import urlparse
else: else:
import _thread as thread import _thread as thread
import http.client as httplib
import urllib.parse as urlparse
from isso import notify, colors from isso import notify, colors
from isso.utils import parse
class IssoParser(ConfigParser):
@classmethod
def _total_seconds(cls, td):
return (td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6) / 10**6
def getint(self, section, key):
try:
delta = parse.timedelta(self.get(section, key))
except ValueError:
return super(IssoParser, self).getint(section, key)
else:
try:
return int(delta.total_seconds())
except AttributeError:
return int(IssoParser._total_seconds(delta))
class Config: class Config:
@ -40,7 +53,10 @@ class Config:
"[general]", "[general]",
"dbpath = /tmp/isso.db", "secretkey = %r" % binascii.b2a_hex(os.urandom(24)), "dbpath = /tmp/isso.db", "secretkey = %r" % binascii.b2a_hex(os.urandom(24)),
"host = http://localhost:8080/", "passphrase = p@$$w0rd", "host = http://localhost:8080/", "passphrase = p@$$w0rd",
"max-age = 900", "moderated = false", "max-age = 15m",
"[moderation]",
"enabled = false",
"purge-after = 30d",
"[server]", "[server]",
"host = localhost", "port = 8080", "reload = off", "host = localhost", "port = 8080", "reload = off",
"[SMTP]", "[SMTP]",
@ -56,7 +72,7 @@ class Config:
@classmethod @classmethod
def load(cls, configfile): def load(cls, configfile):
rv = ConfigParser(allow_no_value=True) rv = IssoParser(allow_no_value=True)
rv.read_file(io.StringIO(u'\n'.join(Config.default))) rv.read_file(io.StringIO(u'\n'.join(Config.default)))
if configfile: if configfile:
@ -65,6 +81,28 @@ class Config:
return rv return rv
def SMTP(conf):
try:
print(" * connecting to SMTP server", end=" ")
mailer = notify.SMTPMailer(conf)
print("[%s]" % colors.green("ok"))
except (socket.error, smtplib.SMTPException):
print("[%s]" % colors.red("failed"))
mailer = notify.NullMailer()
return mailer
class Mixin(object):
def __init__(self, conf):
self.lock = threading.Lock()
def notify(self, subject, body, retries=5):
pass
def threaded(func): def threaded(func):
def dec(self, *args, **kwargs): def dec(self, *args, **kwargs):
@ -73,42 +111,17 @@ def threaded(func):
return dec return dec
class Mixin(object): class ThreadedMixin(Mixin):
def __init__(self, *args):
self.lock = threading.Lock()
def notify(self, subject, body, retries=5):
pass
class NaiveMixin(Mixin):
def __init__(self, conf): def __init__(self, conf):
super(NaiveMixin, self).__init__() super(ThreadedMixin, self).__init__(conf)
try: if conf.getboolean("moderation", "enabled"):
print(" * connecting to SMTP server", end=" ") self.purge(conf.getint("moderation", "purge-after"))
mailer = notify.SMTPMailer(conf)
print("[%s]" % colors.green("ok"))
except (socket.error, smtplib.SMTPException):
print("[%s]" % colors.red("failed"))
mailer = notify.NullMailer()
self.mailer = mailer self.mailer = SMTP(conf)
if not conf.get("general", "host").startswith(("http://", "https://")):
raise SystemExit("error: host must start with http:// or https://")
try:
print(" * connecting to HTTP server", end=" ")
rv = urlparse.urlparse(conf.get("general", "host"))
host = (rv.netloc + ':443') if rv.scheme == 'https' else rv.netloc
httplib.HTTPConnection(host, timeout=5).request('GET', rv.path)
print("[%s]" % colors.green("ok"))
except (httplib.HTTPException, socket.error):
print("[%s]" % colors.red("failed"))
@threaded @threaded
def notify(self, subject, body, retries=5): def notify(self, subject, body, retries=5):
@ -121,8 +134,15 @@ class NaiveMixin(Mixin):
else: else:
break break
@threaded
def purge(self, delta):
while True:
with self.lock:
self.db.comments.purge(delta)
time.sleep(delta)
class uWSGIMixin(NaiveMixin):
class uWSGIMixin(Mixin):
def __init__(self, conf): def __init__(self, conf):
@ -148,7 +168,16 @@ class uWSGIMixin(NaiveMixin):
return uwsgi.SPOOL_OK return uwsgi.SPOOL_OK
self.lock = Lock() self.lock = Lock()
self.mailer = SMTP(conf)
uwsgi.spooler = spooler uwsgi.spooler = spooler
timedelta = conf.getint("moderation", "purge-after")
purge = lambda signum: self.db.comments.purge(timedelta)
uwsgi.register_signal(1, "", purge)
uwsgi.add_timer(1, timedelta)
# run purge once
purge(1)
def notify(self, subject, body, retries=5): def notify(self, subject, body, retries=5):
uwsgi.spool({"subject": subject.encode('utf-8'), "body": body.encode('utf-8')}) uwsgi.spool({"subject": subject.encode('utf-8'), "body": body.encode('utf-8')})

View File

@ -183,3 +183,9 @@ class Comments:
'SELECT COUNT(comments.id) FROM comments INNER JOIN threads ON', 'SELECT COUNT(comments.id) FROM comments INNER JOIN threads ON',
' threads.uri=? AND comments.tid=threads.id AND comments.mode=1;'], ' threads.uri=? AND comments.tid=threads.id AND comments.mode=1;'],
(uri, )).fetchone() (uri, )).fetchone()
def purge(self, delta):
self.db.execute([
'DELETE FROM comments WHERE mode = 2 AND ? - created > ?;'
], (time.time(), delta))
self._remove_stale()

63
isso/utils/parse.py Normal file
View File

@ -0,0 +1,63 @@
from __future__ import print_function
import re
import datetime
try:
from urlparse import urlparse
except ImportError:
from urllib.parse import urlparse
def timedelta(value):
"""
Parse :param value: into :class:`datetime.timedelta`, you can use any (logical)
combination of Nw, Nd, Nh and Nm, e.g. `1h30m` for 1 hour, 30 minutes or `3w` for
3 weeks. Raises a ValueError if the input is invalid/unparseable.
>>> print(timedelta("3w"))
21 days, 0:00:00
>>> print(timedelta("3w 12h 57m"))
21 days, 12:57:00
>>> print(timedelta("1h30m37s"))
1:30:37
>>> print(timedelta("1asdf3w"))
Traceback (most recent call last):
...
ValueError: invalid human-readable timedelta
"""
keys = ["weeks", "days", "hours", "minutes", "seconds"]
regex = "".join(["((?P<%s>\d+)%s ?)?" % (k, k[0]) for k in keys])
kwargs = {}
for k, v in re.match(regex, value).groupdict(default="0").items():
kwargs[k] = int(v)
rv = datetime.timedelta(**kwargs)
if rv == datetime.timedelta():
raise ValueError("invalid human-readable timedelta")
return datetime.timedelta(**kwargs)
def host(name):
"""
Parse :param name: into `httplib`-compatible host:port.
>>> print(host("http://example.tld/"))
('example.tld', 80)
>>> print(host("https://example.tld/"))
('example.tld', 443)
>>> print(host("example.tld"))
('example.tld', 80)
>>> print(host("example.tld:42"))
('example.tld', 42)
"""
if not name.startswith(('http://', 'https://')):
name = 'http://' + name
rv = urlparse(name)
if rv.scheme == 'https':
return (rv.netloc, 443)
return (rv.netloc.rsplit(':')[0], rv.port or 80)

View File

@ -67,7 +67,7 @@ def new(app, environ, request, uri):
if data.get(field): if data.get(field):
data[field] = cgi.escape(data[field]) data[field] = cgi.escape(data[field])
data['mode'] = (app.conf.getboolean('general', 'moderated') and 2) or 1 data['mode'] = (app.conf.getboolean('moderation', 'enabled') and 2) or 1
data['remote_addr'] = utils.anonymize(str(request.remote_addr)) data['remote_addr'] = utils.anonymize(str(request.remote_addr))
with app.lock: with app.lock:
@ -90,7 +90,7 @@ def new(app, environ, request, uri):
deletion = host + environ["SCRIPT_NAME"] + "/delete/" + app.sign(str(rv["id"])) deletion = host + environ["SCRIPT_NAME"] + "/delete/" + app.sign(str(rv["id"]))
activation = None activation = None
if app.conf.getboolean('general', 'moderated'): if app.conf.getboolean('moderation', 'enabled'):
activation = host + environ["SCRIPT_NAME"] + "/activate/" + app.sign(str(rv["id"])) activation = host + environ["SCRIPT_NAME"] + "/activate/" + app.sign(str(rv["id"]))
app.notify(title, notify.format(rv, href, utils.anonymize(str(request.remote_addr)), app.notify(title, notify.format(rv, href, utils.anonymize(str(request.remote_addr)),

View File

@ -264,7 +264,7 @@ class TestModeratedComments(unittest.TestCase):
fd, self.path = tempfile.mkstemp() fd, self.path = tempfile.mkstemp()
conf = core.Config.load(None) conf = core.Config.load(None)
conf.set("general", "dbpath", self.path) conf.set("general", "dbpath", self.path)
conf.set("general", "moderated", "true") conf.set("moderation", "enabled", "true")
conf.set("guard", "enabled", "off") conf.set("guard", "enabled", "off")
class App(Isso, core.Mixin): class App(Isso, core.Mixin):
@ -287,3 +287,35 @@ class TestModeratedComments(unittest.TestCase):
self.app.db.comments.activate(1) self.app.db.comments.activate(1)
assert self.client.get('/?uri=test').status_code == 200 assert self.client.get('/?uri=test').status_code == 200
class TestPurgeComments(unittest.TestCase):
def setUp(self):
fd, self.path = tempfile.mkstemp()
conf = core.Config.load(None)
conf.set("general", "dbpath", self.path)
conf.set("moderation", "enabled", "true")
conf.set("guard", "enabled", "off")
class App(Isso, core.Mixin):
pass
self.app = App(conf)
self.app.wsgi_app = FakeIP(self.app.wsgi_app)
self.client = Client(self.app, Response)
def testPurgeDoesNoHarm(self):
self.client.post('/new?uri=test', data=json.dumps({"text": "..."}))
self.app.db.comments.activate(1)
self.app.db.comments.purge(0)
assert self.client.get('/?uri=test').status_code == 200
def testPurgeWorks(self):
self.client.post('/new?uri=test', data=json.dumps({"text": "..."}))
self.app.db.comments.purge(0)
assert self.client.get('/id/1').status_code == 404
self.client.post('/new?uri=test', data=json.dumps({"text": "..."}))
self.app.db.comments.purge(3600)
assert self.client.get('/id/1').status_code == 200

View File

@ -16,4 +16,5 @@ deps =
nose nose
ipaddress ipaddress
commands= commands=
nosetests --with-doctest isso/
nosetests specs/ nosetests specs/