Merge branch 'feature/spam-guard'
This commit is contained in:
commit
2557c02117
@ -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),
|
||||||
|
43
isso/core.py
43
isso/core.py
@ -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()
|
||||||
|
@ -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)
|
||||||
|
@ -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
47
isso/db/spam.py
Normal 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
|
@ -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);
|
||||||
|
@ -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 {
|
||||||
|
@ -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:
|
||||||
|
with app.lock:
|
||||||
rv = app.db.comments.add(uri, data)
|
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':
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
Loading…
Reference in New Issue
Block a user