Merge branch 'feature/spam-guard'

This commit is contained in:
Martin Zimmermann 2013-10-06 19:55:44 +02:00
commit 2557c02117
10 changed files with 149 additions and 36 deletions

View File

@ -79,20 +79,8 @@ class Isso(object):
super(Isso, self).__init__(conf) super(Isso, self).__init__(conf)
if not conf.get("general", "host").startswith(("http://", "https://")):
sys.exit("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"))
self.conf = conf self.conf = conf
self.db = db.SQLite3(conf.get('general', 'dbpath')) 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/')))
@ -100,7 +88,7 @@ class Isso(object):
return self.signer.dumps(obj) return self.signer.dumps(obj)
def unsign(self, obj): def unsign(self, obj):
return self.signer.loads(obj, max_age=self.MAX_AGE) return self.signer.loads(obj, max_age=self.conf.getint('general', 'max-age'))
def markdown(self, text): def markdown(self, text):
return misaka.html(text, extensions=misaka.EXT_STRIKETHROUGH \ return misaka.html(text, extensions=misaka.EXT_STRIKETHROUGH \
@ -121,7 +109,7 @@ class Isso(object):
except MethodNotAllowed: except MethodNotAllowed:
return Response("Yup.", 200) return Response("Yup.", 200)
except HTTPException as e: except HTTPException as e:
return Response(e, 500) return e
def wsgi_app(self, environ, start_response): def wsgi_app(self, environ, start_response):
response = self.dispatch(Request(environ), start_response) response = self.dispatch(Request(environ), start_response)
@ -141,9 +129,13 @@ def make_app(conf=None):
try: try:
import uwsgi import uwsgi
except ImportError: except ImportError:
isso = type("Isso", (Isso, NaiveMixin), {})(conf) class App(Isso, NaiveMixin):
pass
else: else:
isso = type("Isso", (Isso, uWSGIMixin), {})(conf) class App(Isso, uWSGIMixin):
pass
isso = App(conf)
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/'),
@ -172,7 +164,8 @@ def main():
conf = Config.load(args.conf) conf = Config.load(args.conf)
if args.command == "import": if args.command == "import":
migrate.disqus(db.SQLite3(conf.get('general', 'dbpath')), args.dump) conf.set("guard", "enabled", "off")
migrate.disqus(db.SQLite3(conf.get('general', 'dbpath'), conf), args.dump)
sys.exit(0) sys.exit(0)
run_simple(conf.get('server', 'host'), conf.getint('server', 'port'), make_app(conf), run_simple(conf.get('server', 'host'), conf.getint('server', 'port'), make_app(conf),

View File

@ -12,6 +12,9 @@ import threading
import socket import socket
import smtplib import smtplib
import httplib
import urlparse
from ConfigParser import ConfigParser from ConfigParser import ConfigParser
try: try:
@ -21,19 +24,24 @@ except ImportError:
from isso import notify, colors from isso import notify, colors
class Config: class Config:
default = [ default = [
"[general]", "[general]",
"dbpath = /tmp/isso.db", "secretkey = %r" % os.urandom(24), "dbpath = /tmp/isso.db", "secretkey = %r" % os.urandom(24),
"host = http://localhost:8080/", "passphrase = p@$$w0rd", "host = http://localhost:8080/", "passphrase = p@$$w0rd",
"max_age = 450", "max-age = 900",
"[server]", "[server]",
"host = localhost", "port = 8080", "reload = off", "host = localhost", "port = 8080", "reload = off",
"[SMTP]", "[SMTP]",
"username = ", "password = ", "username = ", "password = ",
"host = localhost", "port = 465", "ssl = on", "host = localhost", "port = 465", "ssl = on",
"to = ", "from = " "to = ", "from = ",
"[guard]",
"enabled = on",
"ratelimit = 2"
""
] ]
@classmethod @classmethod
@ -56,10 +64,21 @@ def threaded(func):
return dec return dec
class NaiveMixin(object): class Mixin(object):
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__()
try: try:
print(" * connecting to SMTP server", end=" ") print(" * connecting to SMTP server", end=" ")
mailer = notify.SMTPMailer(conf) mailer = notify.SMTPMailer(conf)
@ -69,7 +88,18 @@ class NaiveMixin(object):
mailer = notify.NullMailer() mailer = notify.NullMailer()
self.mailer = mailer self.mailer = mailer
self.lock = threading.Lock()
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):
@ -86,15 +116,16 @@ class NaiveMixin(object):
class uWSGIMixin(NaiveMixin): class uWSGIMixin(NaiveMixin):
def __init__(self, conf): def __init__(self, conf):
super(uWSGIMixin, self).__init__(conf) super(uWSGIMixin, self).__init__(conf)
class Lock(): class Lock():
def __enter__(self): def __enter__(self):
while uwsgi.queue_get(0) == "LOCK": while uwsgi.queue_get(0) == "LOCK":
time.sleep(0.1) time.sleep(0.01)
uwsgi.queue_set(uwsgi.queue_slot(), "LOCK") uwsgi.queue_set(0, "LOCK")
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):
uwsgi.queue_pop() uwsgi.queue_pop()

View File

@ -2,18 +2,20 @@
import sqlite3 import sqlite3
class IssoDBException(Exception):
pass
from isso.db.comments import Comments from isso.db.comments import Comments
from isso.db.threads import Threads from isso.db.threads import Threads
class SQLite3: class SQLite3:
connection = None def __init__(self, path, conf):
def __init__(self, path, moderation=False):
self.path = path self.path = path
self.mode = 2 if moderation else 1 self.conf = conf
self.mode = 1
self.threads = Threads(self) self.threads = Threads(self)
self.comments = Comments(self) self.comments = Comments(self)

View File

@ -2,6 +2,7 @@
import time import time
from isso.db import spam
from isso.utils import Bloomfilter from isso.utils import Bloomfilter
@ -31,6 +32,7 @@ class Comments:
' text VARCHAR, author VARCHAR, email VARCHAR, website VARCHAR,', ' text VARCHAR, author VARCHAR, email VARCHAR, website VARCHAR,',
' likes INTEGER DEFAULT 0, dislikes INTEGER DEFAULT 0, voters BLOB NOT NULL);']) ' likes INTEGER DEFAULT 0, dislikes INTEGER DEFAULT 0, voters BLOB NOT NULL);'])
@spam.check
def add(self, uri, c): def add(self, uri, c):
""" """
Add a new comment to the database and return public fields as dict. Add a new comment to the database and return public fields as dict.

47
isso/db/spam.py Normal file
View File

@ -0,0 +1,47 @@
# -*- encoding: utf-8 -*-
import time
from isso.db import IssoDBException
class TooManyComments(IssoDBException):
pass
def check(func):
def dec(self, uri, c):
if not self.db.conf.getboolean("guard", "enabled"):
return func(self, uri, c)
# block more than two comments per minute
rv = self.db.execute([
'SELECT id FROM comments WHERE remote_addr = ? AND created > ?;'
], (c["remote_addr"], time.time() + 60)).fetchall()
if len(rv) >= 2:
raise TooManyComments
# block more than three comments as direct response to the post
rv = self.db.execute([
'SELECT id FROM comments WHERE remote_addr = ? AND parent IS NULL;'
], (c["remote_addr"], )).fetchall()
if len(rv) >= 3:
raise TooManyComments
# block reply to own comment if the cookie is still available (max age)
if "parent" in c:
rv = self.db.execute([
'SELECT id FROM comments WHERE remote_addr = ? AND id = ? AND ? - created < ?;'
], (c["remote_addr"], c["parent"], time.time(),
self.db.conf.getint("general", "max-age"))).fetchall()
if len(rv) > 0:
raise TooManyComments
return func(self, uri, c)
return dec

View File

@ -77,6 +77,15 @@ define(function() {
}); });
}; };
window.Element.prototype.detach = function() {
/*
Detach an element from the DOM and return it.
*/
this.parentNode.removeChild(this);
return this;
};
window.Element.prototype.remove = function() { window.Element.prototype.remove = function() {
// Mimimi, I am IE and I am so retarded, mimimi. // Mimimi, I am IE and I am so retarded, mimimi.
this.parentNode.removeChild(this); this.parentNode.removeChild(this);

View File

@ -245,6 +245,19 @@ define(["behave", "app/text/html", "app/dom", "app/utils", "app/api", "app/marku
clear("a.edit"); clear("a.edit");
clear("a.delete"); clear("a.delete");
// show direct reply to own comment when cookie is max aged
var show = function(el) {
if (utils.cookie(comment.id)) {
setTimeout(function() { show(el); }, 15*1000);
} else {
footer.append(el);
}
};
if (utils.cookie(comment.id)) {
show($("a.reply", footer).detach());
}
}; };
return { return {

View File

@ -12,7 +12,7 @@ from itsdangerous import SignatureExpired, BadSignature
from werkzeug.wrappers import Response from werkzeug.wrappers import Response
from werkzeug.exceptions import abort, BadRequest from werkzeug.exceptions import abort, BadRequest
from isso import utils, notify from isso import utils, notify, db
from isso.crypto import pbkdf2 from isso.crypto import pbkdf2
FIELDS = {'id', 'parent', 'text', 'author', 'website', 'email', 'mode', 'created', FIELDS = {'id', 'parent', 'text', 'author', 'website', 'email', 'mode', 'created',
@ -73,10 +73,13 @@ def new(app, environ, request, uri):
title = app.db.threads[uri].title title = app.db.threads[uri].title
try: try:
rv = app.db.comments.add(uri, data) with app.lock:
rv = app.db.comments.add(uri, data)
except sqlite3.Error: except sqlite3.Error:
logging.exception('uncaught SQLite3 exception') logging.exception('uncaught SQLite3 exception')
abort(400) abort(400)
except db.IssoDBException:
abort(403)
href = (app.conf.get('general', 'host').rstrip("/") + uri + "#isso-%i" % rv["id"]) href = (app.conf.get('general', 'host').rstrip("/") + uri + "#isso-%i" % rv["id"])
app.notify(title, notify.format(rv, href, utils.anonymize(unicode(request.remote_addr)))) app.notify(title, notify.format(rv, href, utils.anonymize(unicode(request.remote_addr))))
@ -93,7 +96,7 @@ def new(app, environ, request, uri):
resp = Response(json.dumps(rv), 202 if rv["mode"] == 2 else 201, resp = Response(json.dumps(rv), 202 if rv["mode"] == 2 else 201,
content_type='application/json') content_type='application/json')
resp.set_cookie(str(rv["id"]), app.sign([rv["id"], checksum]), max_age=app.conf.getint('general', 'max_age')) resp.set_cookie(str(rv["id"]), app.sign([rv["id"], checksum]), max_age=app.conf.getint('general', 'max-age'))
return resp return resp
@ -154,7 +157,7 @@ def single(app, environ, request, id):
rv["text"] = app.markdown(rv["text"]) rv["text"] = app.markdown(rv["text"])
resp = Response(json.dumps(rv), 200, content_type='application/json') resp = Response(json.dumps(rv), 200, content_type='application/json')
resp.set_cookie(str(rv["id"]), app.sign([rv["id"], checksum]), max_age=app.MAX_AGE) resp.set_cookie(str(rv["id"]), app.sign([rv["id"], checksum]), max_age=app.conf.getint('general', 'max-age'))
return resp return resp
if request.method == 'DELETE': if request.method == 'DELETE':

View File

@ -10,7 +10,7 @@ import unittest
from werkzeug.test import Client from werkzeug.test import Client
from werkzeug.wrappers import Response from werkzeug.wrappers import Response
from isso import Isso, notify, utils, views from isso import Isso, notify, utils, views, core
from isso.views import comment from isso.views import comment
utils.heading = lambda *args: "Untitled." utils.heading = lambda *args: "Untitled."
@ -31,8 +31,14 @@ class TestComments(unittest.TestCase):
def setUp(self): def setUp(self):
fd, self.path = tempfile.mkstemp() fd, self.path = tempfile.mkstemp()
conf = core.Config.load(None)
conf.set("general", "dbpath", self.path)
conf.set("guard", "enabled", "off")
self.app = Isso(self.path, '...', '...', 15*60, "...", notify.NullMailer()) class App(Isso, core.Mixin):
pass
self.app = App(conf)
self.app.wsgi_app = FakeIP(self.app.wsgi_app) self.app.wsgi_app = FakeIP(self.app.wsgi_app)
self.client = Client(self.app, Response) self.client = Client(self.app, Response)

View File

@ -9,7 +9,7 @@ import unittest
from werkzeug.test import Client from werkzeug.test import Client
from werkzeug.wrappers import Response from werkzeug.wrappers import Response
from isso import Isso, notify, utils from isso import Isso, notify, utils, core
utils.heading = lambda *args: "Untitled." utils.heading = lambda *args: "Untitled."
utils.urlexists = lambda *args: True utils.urlexists = lambda *args: True
@ -36,7 +36,14 @@ class TestVote(unittest.TestCase):
def makeClient(self, ip): def makeClient(self, ip):
app = Isso(self.path, '...', '...', 15*60, "...", notify.NullMailer()) conf = core.Config.load(None)
conf.set("general", "dbpath", self.path)
conf.set("guard", "enabled", "off")
class App(Isso, core.Mixin):
pass
app = App(conf)
app.wsgi_app = FakeIP(app.wsgi_app, ip) app.wsgi_app = FakeIP(app.wsgi_app, ip)
return Client(app, Response) return Client(app, Response)