Merge branch 'tmp/refactorization'

Conflicts:
	isso/utils/__init__.py
This commit is contained in:
Martin Zimmermann 2013-11-11 12:10:29 +01:00
commit d4f2123b58
17 changed files with 661 additions and 502 deletions

View File

@ -36,6 +36,7 @@ session key and hostname. Here are the default values for this section:
host = http://localhost:8080/
max-age = 15m
session-key = ... # python: binascii.b2a_hex(os.urandom(24))
notify =
dbpath
file location to the SQLite3 database, highly recommended to change this
@ -67,6 +68,10 @@ max-age
time range that allows users to edit/remove their own comments. See
:ref:`Appendum: Timedelta <appendum-timedelta>` for valid values.
notify
Select notification backend for new comments. Currently, only SMTP
is available.
Moderation
----------
@ -131,8 +136,8 @@ SMTP
----
Isso can notify you on new comments via SMTP. In the email notification, you
also can moderate comments. If the server connection fails during startup, a
null mailer is used.
also can moderate (=activate or delete) comments. Don't forget to configure
``notify = smtp`` in the general section.
.. code-block:: ini

View File

@ -46,25 +46,25 @@ import logging
from os.path import dirname, join
from argparse import ArgumentParser
try:
import httplib
except ImportError:
import http.client as httplib
import misaka
from itsdangerous import URLSafeTimedSerializer
from werkzeug.routing import Map, Rule
from werkzeug.routing import Map
from werkzeug.exceptions import HTTPException, InternalServerError
from werkzeug.wsgi import SharedDataMiddleware
from werkzeug.local import Local, LocalManager
from werkzeug.serving import run_simple, WSGIRequestHandler
from werkzeug.contrib.fixers import ProxyFix
from isso import db, migrate, views, wsgi
local = Local()
local_manager = LocalManager([local])
from isso import db, migrate, wsgi, ext
from isso.core import ThreadedMixin, uWSGIMixin, Config
from isso.utils import parse, http, JSONRequest
from isso.views import comment
from isso.utils import parse, http, JSONRequest, origin
from isso.views import comments
from isso.ext.notifications import Stdout, SMTP
logging.getLogger('werkzeug').setLevel(logging.ERROR)
logging.basicConfig(
@ -77,37 +77,6 @@ logger = logging.getLogger("isso")
class Isso(object):
salt = b"Eech7co8Ohloopo9Ol6baimi"
urls = Map([
Rule('/new', methods=['POST'], endpoint=views.comment.new),
Rule('/id/<int:id>', methods=['GET', 'PUT', 'DELETE'], endpoint=views.comment.single),
Rule('/id/<int:id>/like', methods=['POST'], endpoint=views.comment.like),
Rule('/id/<int:id>/dislike', methods=['POST'], endpoint=views.comment.dislike),
Rule('/', methods=['GET'], endpoint=views.comment.fetch),
Rule('/count', methods=['GET'], endpoint=views.comment.count),
Rule('/delete/<string:auth>', endpoint=views.comment.delete),
Rule('/activate/<string:auth>', endpoint=views.comment.activate),
Rule('/check-ip', endpoint=views.comment.checkip)
])
@classmethod
def CORS(cls, request, response, hosts):
for host in hosts:
if request.environ.get("HTTP_ORIGIN", None) == host.rstrip("/"):
origin = host.rstrip("/")
break
else:
origin = host.rstrip("/")
hdrs = response.headers
hdrs["Access-Control-Allow-Origin"] = origin
hdrs["Access-Control-Allow-Headers"] = "Origin, Content-Type"
hdrs["Access-Control-Allow-Credentials"] = "true"
hdrs["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE"
return response
def __init__(self, conf):
@ -117,19 +86,30 @@ class Isso(object):
super(Isso, self).__init__(conf)
subscribers = []
subscribers.append(Stdout(None))
if conf.get("general", "notify") == "smtp":
subscribers.append(SMTP(self))
self.signal = ext.Signal(*subscribers)
self.urls = Map()
self.api = comments.API(self)
def sign(self, obj):
return self.signer.dumps(obj)
def unsign(self, obj, max_age=None):
return self.signer.loads(obj, max_age=max_age or self.conf.getint('general', 'max-age'))
def markdown(self, text):
return misaka.html(text, extensions=misaka.EXT_STRIKETHROUGH
| misaka.EXT_SUPERSCRIPT | misaka.EXT_AUTOLINK
| misaka.HTML_SKIP_HTML | misaka.HTML_SKIP_IMAGES | misaka.HTML_SAFELINK)
def dispatch(self, request):
adapter = Isso.urls.bind_to_environ(request.environ)
local.request = request
local.host = wsgi.host(request.environ)
local.origin = origin(self.conf.getiter("general", "host"))(request.environ)
adapter = self.urls.bind_to_environ(request.environ)
try:
handler, values = adapter.match()
@ -137,7 +117,7 @@ class Isso(object):
return e
else:
try:
response = handler(self, request.environ, request, **values)
response = handler(request.environ, request, **values)
except HTTPException as e:
return e
except Exception:
@ -147,7 +127,6 @@ class Isso(object):
return response
def wsgi_app(self, environ, start_response):
response = self.dispatch(JSONRequest(environ))
return response(environ, start_response)
@ -186,10 +165,11 @@ def make_app(conf=None):
wsgi.SubURI(
wsgi.CORSMiddleware(
SharedDataMiddleware(
ProfilerMiddleware(isso), {
ProfilerMiddleware(
local_manager.make_middleware(isso)), {
'/js': join(dirname(__file__), 'js/'),
'/css': join(dirname(__file__), 'css/')}),
list(isso.conf.getiter("general", "host")))))
origin(isso.conf.getiter("general", "host")))))
return app

View File

@ -9,9 +9,6 @@ import binascii
import threading
import logging
import socket
import smtplib
from configparser import ConfigParser
try:
@ -26,7 +23,6 @@ if PY2K:
else:
import _thread as thread
from isso import notify
from isso.utils import parse
from isso.compat import text_type as str
@ -35,6 +31,25 @@ from werkzeug.contrib.cache import NullCache, SimpleCache
logger = logging.getLogger("isso")
class Section:
def __init__(self, conf, section):
self.conf = conf
self.section = section
def get(self, key):
return self.conf.get(self.section, key)
def getint(self, key):
return self.conf.getint(self.section, key)
def getiter(self, key):
return self.conf.getiter(self.section, key)
def getboolean(self, key):
return self.conf.getboolean(self.section, key)
class IssoParser(ConfigParser):
"""
Extended :class:`ConfigParser` to parse human-readable timedeltas
@ -81,6 +96,9 @@ class IssoParser(ConfigParser):
if item:
yield item
def section(self, section):
return Section(self, section)
class Config:
@ -88,6 +106,7 @@ class Config:
"[general]",
"dbpath = /tmp/isso.db", "session-key = %r" % binascii.b2a_hex(os.urandom(24)),
"host = http://localhost:8080/", "max-age = 15m",
"notify = ",
"[moderation]",
"enabled = false",
"purge-after = 30d",
@ -128,18 +147,6 @@ class Config:
return rv
def SMTP(conf):
try:
mailer = notify.SMTPMailer(conf)
logger.info("connected to SMTP server")
except (socket.error, smtplib.SMTPException):
logger.warn("unable to connect to SMTP server")
mailer = notify.NullMailer()
return mailer
class Cache:
"""Wrapper around werkzeug's cache class, to make it compatible to
uWSGI's cache framework.
@ -188,20 +195,8 @@ class ThreadedMixin(Mixin):
if conf.getboolean("moderation", "enabled"):
self.purge(conf.getint("moderation", "purge-after"))
self.mailer = SMTP(conf)
self.cache = Cache(SimpleCache(threshold=1024, default_timeout=3600))
@threaded
def notify(self, subject, body, retries=5):
for x in range(retries):
try:
self.mailer.sendmail(subject, body)
except Exception:
time.sleep(60)
else:
break
@threaded
def purge(self, delta):
while True:
@ -246,18 +241,7 @@ class uWSGIMixin(Mixin):
def __exit__(self, exc_type, exc_val, exc_tb):
uwsgi.unlock()
def spooler(args):
try:
self.mailer.sendmail(args["subject"].decode('utf-8'), args["body"].decode('utf-8'))
except smtplib.SMTPConnectError:
return uwsgi.SPOOL_RETRY
else:
return uwsgi.SPOOL_OK
uwsgi.spooler = spooler
self.lock = Lock()
self.mailer = SMTP(conf)
self.cache = uWSGICache
timedelta = conf.getint("moderation", "purge-after")
@ -267,6 +251,3 @@ class uWSGIMixin(Mixin):
# run purge once
purge(1)
def notify(self, subject, body, retries=5):
uwsgi.spool({"subject": subject.encode('utf-8'), "body": body.encode('utf-8')})

View File

@ -1,12 +1,12 @@
# -*- encoding: utf-8 -*-
class Thread(object):
def __init__(self, id, uri, title):
self.id = id
self.uri = uri
self.title = title
def Thread(id, uri, title):
return {
"id": id,
"uri": uri,
"title": title
}
class Threads(object):
@ -27,3 +27,4 @@ class Threads(object):
def new(self, uri, title):
self.db.execute("INSERT INTO threads (uri, title) VALUES (?, ?)", (uri, title))
return self[uri]

17
isso/ext/__init__.py Normal file
View File

@ -0,0 +1,17 @@
# -*- encoding: utf-8 -*-
from collections import defaultdict
class Signal(object):
def __init__(self, *subscriber):
self.subscriptions = defaultdict(list)
for sub in subscriber:
for signal, func in sub:
self.subscriptions[signal].append(func)
def __call__(self, origin, *args, **kwargs):
for subscriber in self.subscriptions[origin]:
subscriber(*args, **kwargs)

160
isso/ext/notifications.py Normal file
View File

@ -0,0 +1,160 @@
# -*- encoding: utf-8 -*-
from __future__ import unicode_literals
import time
import json
import socket
import smtplib
from email.header import Header
from email.mime.text import MIMEText
import logging
logger = logging.getLogger("isso")
try:
import uwsgi
except ImportError:
uwsgi = None
from isso.compat import PY2K
from isso import local
if PY2K:
from thread import start_new_thread
else:
from _thread import start_new_thread
class SMTP(object):
def __init__(self, isso):
self.isso = isso
self.conf = isso.conf.section("smtp")
# test SMTP connectivity
try:
with self:
logger.info("connected to SMTP server")
except (socket.error, smtplib.SMTPException):
logger.warn("unable to connect to SMTP server")
if uwsgi:
def spooler(args):
try:
self._sendmail(args["subject"].decode("utf-8"),
args["body"].decode("utf-8"))
except smtplib.SMTPConnectError:
return uwsgi.SPOOL_RETRY
else:
return uwsgi.SPOOL_OK
uwsgi.spooler = spooler
def __enter__(self):
klass = (smtplib.SMTP_SSL if self.conf.getboolean('ssl') else smtplib.SMTP)
self.client = klass(host=self.conf.get('host'), port=self.conf.getint('port'))
if self.conf.get('username') and self.conf.get('password'):
self.client.login(self.conf.get('username'),
self.conf.get('password'))
return self.client
def __exit__(self, exc_type, exc_value, traceback):
self.client.quit()
def __iter__(self):
yield "comments.new:after-save", self.notify
def format(self, thread, comment):
permalink = local("origin") + thread["uri"] + "#isso-%i" % comment["id"]
rv = []
rv.append("%s schrieb:" % (comment["author"] or "Jemand"))
rv.append("")
rv.append(comment["text"])
rv.append("")
if comment["website"]:
rv.append("Webseite des Kommentators: %s" % comment["website"])
rv.append("IP Adresse: %s" % comment["remote_addr"])
rv.append("Link zum Kommentar: %s" % permalink)
rv.append("")
uri = local("host") + "/id/%i" % comment["id"]
key = self.isso.sign(comment["id"])
rv.append("---")
rv.append("Kommentar löschen: %s" % uri + "/delete/" + key)
if comment["mode"] == 2:
rv.append("Kommentar freischalten: %s" % uri + "/activate/" + key)
return u'\n'.join(rv)
def notify(self, thread, comment):
body = self.format(thread, comment)
if uwsgi:
uwsgi.spool({b"subject": thread["title"].encode("utf-8"),
b"body": body.encode("utf-8")})
else:
start_new_thread(self._retry, (thread["title"], body))
def _sendmail(self, subject, body):
from_addr = self.conf.get("from")
to_addr = self.conf.get("to")
msg = MIMEText(body, 'plain', 'utf-8')
msg['From'] = "Ich schrei sonst! <%s>" % from_addr
msg['To'] = to_addr
msg['Subject'] = Header(subject, 'utf-8')
with self as con:
con.sendmail(from_addr, to_addr, msg.as_string())
def _retry(self, subject, body):
for x in range(5):
try:
self._sendmail(subject, body)
except smtplib.SMTPConnectError:
time.sleep(60)
else:
break
class Stdout(object):
def __init__(self, conf):
pass
def __iter__(self):
yield "comments.new:new-thread", self._new_thread
yield "comments.new:finish", self._new_comment
yield "comments.edit", self._edit_comment
yield "comments.delete", self._delete_comment
yield "comments.activate", self._activate_comment
def _new_thread(self, thread):
logger.info("new thread %(id)s: %(title)s" % thread)
def _new_comment(self, thread, comment):
logger.info("comment created: %s", json.dumps(comment))
def _edit_comment(self, comment):
logger.info('comment %i edited: %s', comment["id"], json.dumps(comment))
def _delete_comment(self, id):
logger.info('comment %i deleted', id)
def _activate_comment(self, id):
logger.info("comment %s activated" % id)

View File

@ -1,83 +0,0 @@
# -*- encoding: utf-8 -*-
from __future__ import unicode_literals
from smtplib import SMTP, SMTP_SSL
from email.header import Header
from email.mime.text import MIMEText
def format(comment, permalink, remote_addr, deletion_key, activation_key=None):
rv = []
rv.append("%s schrieb:" % (comment["author"] or "Jemand"))
rv.append("")
rv.append(comment["text"])
rv.append("")
if comment["website"]:
rv.append("Webseite des Kommentators: %s" % comment["website"])
rv.append("IP Adresse: %s" % remote_addr)
rv.append("Link zum Kommentar: %s" % permalink)
rv.append("")
rv.append("---")
rv.append("Kommentar löschen: %s" % deletion_key)
if activation_key:
rv.append("Kommentar freischalten: %s" % activation_key)
return u'\n'.join(rv)
class Connection(object):
"""
Establish connection to SMTP server, optional with authentication, and
close connection afterwards.
"""
def __init__(self, conf):
self.conf = conf
def __enter__(self):
self.server = (SMTP_SSL if self.conf.getboolean('smtp', 'ssl') else SMTP)(
host=self.conf.get('smtp', 'host'), port=self.conf.getint('smtp', 'port'))
if self.conf.get('smtp', 'username') and self.conf.get('smtp', 'password'):
self.server.login(self.conf.get('smtp', 'username'),
self.conf.get('smtp', 'password'))
return self.server
def __exit__(self, exc_type, exc_value, traceback):
self.server.quit()
class SMTPMailer(object):
def __init__(self, conf):
self.conf = conf
self.from_addr = conf.get('smtp', 'from')
self.to_addr = conf.get('smtp', 'to')
# test SMTP connectivity
with Connection(self.conf):
pass
def sendmail(self, subject, body):
msg = MIMEText(body, 'plain', 'utf-8')
msg['From'] = "Ich schrei sonst! <%s>" % self.from_addr
msg['To'] = self.to_addr
msg['Subject'] = Header(subject, 'utf-8')
with Connection(self.conf) as con:
con.sendmail(self.from_addr, self.to_addr, msg.as_string())
class NullMailer(object):
def sendmail(self, subject, body):
pass

View File

@ -19,6 +19,8 @@ try:
except ImportError:
import ipaddr as ipaddress
import misaka
def anonymize(remote_addr):
"""
@ -106,3 +108,23 @@ class JSONRequest(Request):
return json.loads(self.get_data(as_text=True))
except ValueError:
raise BadRequest('Unable to read JSON request')
def markdown(text):
return misaka.html(text, extensions= misaka.EXT_STRIKETHROUGH
| misaka.EXT_SUPERSCRIPT | misaka.EXT_AUTOLINK
| misaka.HTML_SKIP_HTML | misaka.HTML_SKIP_IMAGES | misaka.HTML_SAFELINK)
def origin(hosts):
hosts = [x.rstrip("/") for x in hosts]
def func(environ):
for host in hosts:
if environ.get("HTTP_ORIGIN", None) == host:
return host
else:
return hosts[0]
return func

View File

@ -2,8 +2,6 @@
import socket
from contextlib import closing
try:
import httplib
except ImportError:

View File

@ -0,0 +1,35 @@
# -*- encoding: utf-8 -*-
from werkzeug.exceptions import BadRequest
class requires:
"""Verify that the request URL contains and can parse the parameter.
.. code-block:: python
@requires(int, "id")
def view(..., id):
assert isinstance(id, int)
Returns a 400 Bad Request that contains a specific error message.
"""
def __init__(self, type, param):
self.param = param
self.type = type
def __call__(self, func):
def dec(cls, env, req, *args, **kwargs):
if self.param not in req.args:
raise BadRequest("missing %s query" % self.param)
try:
kwargs[self.param] = self.type(req.args[self.param])
except TypeError:
raise BadRequest("invalid type for %s, expected %s" % (self.param, self.type))
return func(cls, env, req, *args, **kwargs)
return dec

View File

@ -1,292 +0,0 @@
# -*- encoding: utf-8 -*-
import cgi
import json
import time
import hashlib
import logging
import functools
from itsdangerous import SignatureExpired, BadSignature
from werkzeug.http import dump_cookie
from werkzeug.wrappers import Response
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from isso.compat import text_type as str
from isso import utils, notify, db
from isso.utils import http, parse
from isso.crypto import pbkdf2
logger = logging.getLogger("isso")
FIELDS = set(['id', 'parent', 'text', 'author', 'website', 'email', 'mode',
'created', 'modified', 'likes', 'dislikes', 'hash'])
class requires:
def __init__(self, type, param):
self.param = param
self.type = type
def __call__(self, func):
def dec(app, env, req, *args, **kwargs):
if self.param not in req.args:
raise BadRequest("missing %s query" % self.param)
try:
kwargs[self.param] = self.type(req.args[self.param])
except TypeError:
raise BadRequest("invalid type for %s, expected %s" % (self.param, self.type))
return func(app, env, req, *args, **kwargs)
return dec
@requires(str, 'uri')
def new(app, environ, request, uri):
data = request.get_json()
for field in set(data.keys()) - set(['text', 'author', 'website', 'email', 'parent']):
data.pop(field)
if "text" not in data or data["text"] is None or len(data["text"]) < 3:
raise BadRequest("no text given")
if "id" in data and not isinstance(data["id"], int):
raise BadRequest("parent id must be an integer")
if len(data.get("email") or "") > 254:
raise BadRequest("http://tools.ietf.org/html/rfc5321#section-4.5.3")
for field in ("author", "email"):
if data.get(field):
data[field] = cgi.escape(data[field])
data['mode'] = (app.conf.getboolean('moderation', 'enabled') and 2) or 1
data['remote_addr'] = utils.anonymize(str(request.remote_addr))
with app.lock:
if uri not in app.db.threads:
for host in app.conf.getiter('general', 'host'):
with http.curl('GET', host, uri) as resp:
if resp and resp.status == 200:
title = parse.title(resp.read())
break
else:
return Response('URI does not exist', 404)
app.db.threads.new(uri, title)
logger.info('new thread: %s -> %s', uri, title)
else:
title = app.db.threads[uri].title
try:
with app.lock:
rv = app.db.comments.add(uri, data)
except db.IssoDBException:
raise Forbidden
host = list(app.conf.getiter('general', 'host'))[0].rstrip("/")
href = host + uri + "#isso-%i" % rv["id"]
deletion = host + environ["SCRIPT_NAME"] + "/delete/" + app.sign(str(rv["id"]))
activation = None
if app.conf.getboolean('moderation', 'enabled'):
activation = host + environ["SCRIPT_NAME"] + "/activate/" + app.sign(str(rv["id"]))
app.notify(title, notify.format(rv, href, utils.anonymize(str(request.remote_addr)),
activation_key=activation, deletion_key=deletion))
# save checksum of text into cookie, so mallory can't modify/delete a comment, if
# he add a comment, then removed it but not the signed cookie.
checksum = hashlib.md5(rv["text"].encode('utf-8')).hexdigest()
rv["text"] = app.markdown(rv["text"])
rv["hash"] = str(pbkdf2(rv['email'] or rv['remote_addr'], app.salt, 1000, 6))
app.cache.set('hash', (rv['email'] or rv['remote_addr']).encode('utf-8'), rv['hash'])
for key in set(rv.keys()) - FIELDS:
rv.pop(key)
# success!
logger.info('comment created: %s', json.dumps(rv))
cookie = functools.partial(dump_cookie,
value=app.sign([rv["id"], checksum]),
max_age=app.conf.getint('general', 'max-age'))
resp = Response(json.dumps(rv), 202 if rv["mode"] == 2 else 201, content_type='application/json')
resp.headers.add("Set-Cookie", cookie(str(rv["id"])))
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % rv["id"]))
return resp
def single(app, environ, request, id):
if request.method == 'GET':
rv = app.db.comments.get(id)
if rv is None:
raise NotFound
for key in set(rv.keys()) - FIELDS:
rv.pop(key)
if request.args.get('plain', '0') == '0':
rv['text'] = app.markdown(rv['text'])
return Response(json.dumps(rv), 200, content_type='application/json')
try:
rv = app.unsign(request.cookies.get(str(id), ''))
except (SignatureExpired, BadSignature):
try:
rv = app.unsign(request.cookies.get('admin', ''))
except (SignatureExpired, BadSignature):
raise Forbidden
if rv[0] != id:
raise Forbidden
# verify checksum, mallory might skip cookie deletion when he deletes a comment
if rv[1] != hashlib.md5(app.db.comments.get(id)["text"].encode('utf-8')).hexdigest():
raise Forbidden
if request.method == 'PUT':
data = request.get_json()
if "text" not in data or data["text"] is None or len(data["text"]) < 3:
raise BadRequest("no text given")
for key in set(data.keys()) - set(["text", "author", "website"]):
data.pop(key)
data['modified'] = time.time()
with app.lock:
rv = app.db.comments.update(id, data)
for key in set(rv.keys()) - FIELDS:
rv.pop(key)
logger.info('comment %i edited: %s', id, json.dumps(rv))
checksum = hashlib.md5(rv["text"].encode('utf-8')).hexdigest()
rv["text"] = app.markdown(rv["text"])
cookie = functools.partial(dump_cookie,
value=app.sign([rv["id"], checksum]),
max_age=app.conf.getint('general', 'max-age'))
resp = Response(json.dumps(rv), 200, content_type='application/json')
resp.headers.add("Set-Cookie", cookie(str(rv["id"])))
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % rv["id"]))
return resp
if request.method == 'DELETE':
item = app.db.comments.get(id)
app.cache.delete('hash', (item['email'] or item['remote_addr']).encode('utf-8'))
rv = app.db.comments.delete(id)
if rv:
for key in set(rv.keys()) - FIELDS:
rv.pop(key)
logger.info('comment %i deleted', id)
cookie = functools.partial(dump_cookie, expires=0, max_age=0)
resp = Response(json.dumps(rv), 200, content_type='application/json')
resp.headers.add("Set-Cookie", cookie(str(id)))
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % id))
return resp
@requires(str, 'uri')
def fetch(app, environ, request, uri):
rv = list(app.db.comments.fetch(uri))
if not rv:
raise NotFound
for item in rv:
key = item['email'] or item['remote_addr']
val = app.cache.get('hash', key.encode('utf-8'))
if val is None:
val = str(pbkdf2(key, app.salt, 1000, 6))
app.cache.set('hash', key.encode('utf-8'), val)
item['hash'] = val
for key in set(item.keys()) - FIELDS:
item.pop(key)
if request.args.get('plain', '0') == '0':
for item in rv:
item['text'] = app.markdown(item['text'])
return Response(json.dumps(rv), 200, content_type='application/json')
def like(app, environ, request, id):
nv = app.db.comments.vote(True, id, utils.anonymize(str(request.remote_addr)))
return Response(json.dumps(nv), 200)
def dislike(app, environ, request, id):
nv = app.db.comments.vote(False, id, utils.anonymize(str(request.remote_addr)))
return Response(json.dumps(nv), 200)
@requires(str, 'uri')
def count(app, environ, request, uri):
rv = app.db.comments.count(uri)[0]
if rv == 0:
raise NotFound
return Response(json.dumps(rv), 200, content_type='application/json')
def activate(app, environ, request, auth):
try:
id = app.unsign(auth, max_age=2**32)
except (BadSignature, SignatureExpired):
raise Forbidden
with app.lock:
app.db.comments.activate(id)
logger.info("comment %s activated" % id)
return Response("Yo", 200)
def delete(app, environ, request, auth):
try:
id = app.unsign(auth, max_age=2**32)
except (BadSignature, SignatureExpired):
raise Forbidden
with app.lock:
app.db.comments.delete(id)
logger.info("comment %s deleted" % id)
return Response("%s successfully removed" % id)
def checkip(app, env, req):
return Response(utils.anonymize(str(req.remote_addr)), 200)

314
isso/views/comments.py Normal file
View File

@ -0,0 +1,314 @@
# -*- encoding: utf-8 -*-
import cgi
import json
import time
import hashlib
import functools
from itsdangerous import SignatureExpired, BadSignature
from werkzeug.http import dump_cookie
from werkzeug.routing import Rule
from werkzeug.wrappers import Response
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from isso.compat import text_type as str
from isso import utils, local
from isso.utils import http, parse, markdown
from isso.utils.crypto import pbkdf2
from isso.views import requires
def md5(text):
return hashlib.md5(text.encode('utf-8')).hexdigest()
class JSON(Response):
def __init__(self, *args):
return super(JSON, self).__init__(*args, content_type='application/json')
class API(object):
FIELDS = set(['id', 'parent', 'text', 'author', 'website', 'email',
'mode', 'created', 'modified', 'likes', 'dislikes', 'hash'])
# comment fields, that can be submitted
ACCEPT = set(['text', 'author', 'website', 'email', 'parent'])
VIEWS = [
('fetch', ('GET', '/')),
('new', ('POST', '/new')),
('count', ('GET', '/count')),
('view', ('GET', '/id/<int:id>')),
('edit', ('PUT', '/id/<int:id>')),
('delete', ('DELETE', '/id/<int:id>')),
('delete', ('GET', '/id/<int:id>/delete/<string:key>')),
('like', ('POST', '/id/<int:id>/like')),
('dislike', ('POST', '/id/<int:id>/dislike')),
('checkip', ('GET', '/check-ip'))
]
def __init__(self, isso):
self.isso = isso
self.cache = isso.cache
self.signal = isso.signal
self.conf = isso.conf.section("general")
self.moderated = isso.conf.getboolean("moderation", "enabled")
self.threads = isso.db.threads
self.comments = isso.db.comments
for (view, (method, path)) in self.VIEWS:
isso.urls.add(
Rule(path, methods=[method], endpoint=getattr(self, view)))
@classmethod
def verify(cls, comment):
if "text" not in comment:
return False, "text is missing"
if not isinstance(comment.get("parent"), (int, type(None))):
return False, "parent must be an integer or null"
for key in ("text", "author", "website", "email"):
if not isinstance(comment.get(key), (str, type(None))):
return False, "%s must be a string or null" % key
if len(comment["text"]) < 3:
return False, "text is too short (minimum length: 3)"
if len(comment.get("email") or "") > 254:
return False, "http://tools.ietf.org/html/rfc5321#section-4.5.3"
return True, ""
@requires(str, 'uri')
def new(self, environ, request, uri):
data = request.get_json()
for field in set(data.keys()) - API.ACCEPT:
data.pop(field)
for key in ("author", "email", "website", "parent"):
data.setdefault(key, None)
valid, reason = API.verify(data)
if not valid:
return BadRequest(reason)
for field in ("author", "email"):
if data.get(field) is not None:
data[field] = cgi.escape(data[field])
data['mode'] = 2 if self.moderated else 1
data['remote_addr'] = utils.anonymize(str(request.remote_addr))
with self.isso.lock:
if uri not in self.threads:
with http.curl('GET', local("origin"), uri) as resp:
if resp and resp.status == 200:
title = parse.title(resp.read())
else:
return NotFound('URI does not exist')
thread = self.threads.new(uri, title)
self.signal("comments.new:new-thread", thread)
else:
thread = self.threads[uri]
# notify extensions that the new comment is about to save
self.signal("comments.new:before-save", thread, data)
if data is None:
raise Forbidden
with self.isso.lock:
rv = self.comments.add(uri, data)
# notify extension, that the new comment has been successfully saved
self.signal("comments.new:after-save", thread, rv)
cookie = functools.partial(dump_cookie,
value=self.isso.sign([rv["id"], md5(rv["text"])]),
max_age=self.conf.getint('max-age'))
rv["text"] = markdown(rv["text"])
rv["hash"] = str(pbkdf2(rv['email'] or rv['remote_addr'], self.isso.salt, 1000, 6))
self.cache.set('hash', (rv['email'] or rv['remote_addr']).encode('utf-8'), rv['hash'])
for key in set(rv.keys()) - API.FIELDS:
rv.pop(key)
# success!
self.signal("comments.new:finish", thread, rv)
resp = JSON(json.dumps(rv), 202 if rv["mode"] == 2 else 201)
resp.headers.add("Set-Cookie", cookie(str(rv["id"])))
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % rv["id"]))
return resp
def view(self, environ, request, id):
rv = self.comments.get(id)
if rv is None:
raise NotFound
for key in set(rv.keys()) - API.FIELDS:
rv.pop(key)
if request.args.get('plain', '0') == '0':
rv['text'] = markdown(rv['text'])
return Response(json.dumps(rv), 200, content_type='application/json')
def edit(self, environ, request, id):
try:
rv = self.isso.unsign(request.cookies.get(str(id), ''))
except (SignatureExpired, BadSignature):
raise Forbidden
if rv[0] != id:
raise Forbidden
# verify checksum, mallory might skip cookie deletion when he deletes a comment
if rv[1] != md5(self.comments.get(id)["text"]):
raise Forbidden
data = request.get_json()
if "text" not in data or data["text"] is None or len(data["text"]) < 3:
raise BadRequest("no text given")
for key in set(data.keys()) - set(["text", "author", "website"]):
data.pop(key)
data['modified'] = time.time()
with self.isso.lock:
rv = self.comments.update(id, data)
for key in set(rv.keys()) - API.FIELDS:
rv.pop(key)
self.signal("comments.edit", rv)
cookie = functools.partial(dump_cookie,
value=self.isso.sign([rv["id"], md5(rv["text"])]),
max_age=self.conf.getint('max-age'))
rv["text"] = markdown(rv["text"])
resp = JSON(json.dumps(rv), 200)
resp.headers.add("Set-Cookie", cookie(str(rv["id"])))
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % rv["id"]))
return resp
def delete(self, environ, request, id, key=None):
try:
rv = self.isso.unsign(request.cookies.get(str(id), ""))
except (SignatureExpired, BadSignature):
try:
id = self.isso.unsign(key or "", max_age=2**32)
except (BadSignature, SignatureExpired):
raise Forbidden
else:
if rv[0] != id:
raise Forbidden
# verify checksum, mallory might skip cookie deletion when he deletes a comment
if rv[1] != md5(self.comments.get(id)["text"]):
raise Forbidden
item = self.comments.get(id)
if item is None:
raise NotFound
self.cache.delete('hash', (item['email'] or item['remote_addr']).encode('utf-8'))
rv = self.comments.delete(id)
if rv:
for key in set(rv.keys()) - API.FIELDS:
rv.pop(key)
self.signal("comments.delete", id)
resp = JSON(json.dumps(rv), 200)
cookie = functools.partial(dump_cookie, expires=0, max_age=0)
resp.headers.add("Set-Cookie", cookie(str(id)))
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % id))
return resp
def activate(self, environ, request, _, key):
try:
id = self.isso.unsign(key, max_age=2**32)
except (BadSignature, SignatureExpired):
raise Forbidden
with self.isso.lock:
self.comments.activate(id)
self.signal("comments.activate", id)
return Response("Yo", 200)
@requires(str, 'uri')
def fetch(self, environ, request, uri):
rv = list(self.comments.fetch(uri))
if not rv:
raise NotFound
for item in rv:
key = item['email'] or item['remote_addr']
val = self.cache.get('hash', key.encode('utf-8'))
if val is None:
val = str(pbkdf2(key, self.isso.salt, 1000, 6))
self.cache.set('hash', key.encode('utf-8'), val)
item['hash'] = val
for key in set(item.keys()) - API.FIELDS:
item.pop(key)
if request.args.get('plain', '0') == '0':
for item in rv:
item['text'] = markdown(item['text'])
return JSON(json.dumps(rv), 200)
def like(self, environ, request, id):
nv = self.comments.vote(True, id, utils.anonymize(str(request.remote_addr)))
return Response(json.dumps(nv), 200)
def dislike(self, environ, request, id):
nv = self.comments.vote(False, id, utils.anonymize(str(request.remote_addr)))
return Response(json.dumps(nv), 200)
@requires(str, 'uri')
def count(self, environ, request, uri):
rv = self.comments.count(uri)[0]
if rv == 0:
raise NotFound
return JSON(json.dumps(rv), 200)
def checkip(self, env, req):
return Response(utils.anonymize(str(req.remote_addr)), 200)

View File

@ -1,8 +1,36 @@
# -*- encoding: utf-8 -*-
try:
from urllib import quote
except ImportError:
from urllib.parse import quote
from werkzeug.datastructures import Headers
def host(environ):
"""
Reconstruct host from environment. A modified version
of http://www.python.org/dev/peps/pep-0333/#url-reconstruction
"""
url = environ['wsgi.url_scheme']+'://'
if environ.get('HTTP_HOST'):
url += environ['HTTP_HOST']
else:
url += environ['SERVER_NAME']
if environ['wsgi.url_scheme'] == 'https':
if environ['SERVER_PORT'] != '443':
url += ':' + environ['SERVER_PORT']
else:
if environ['SERVER_PORT'] != '80':
url += ':' + environ['SERVER_PORT']
return url + quote(environ.get('SCRIPT_NAME', ''))
class SubURI(object):
def __init__(self, app):
@ -22,23 +50,15 @@ class SubURI(object):
class CORSMiddleware(object):
def __init__(self, app, hosts):
def __init__(self, app, origin):
self.app = app
self.hosts = hosts
self.origin = origin
def __call__(self, environ, start_response):
def add_cors_headers(status, headers, exc_info=None):
for host in self.hosts:
if environ.get("HTTP_ORIGIN", None) == host.rstrip("/"):
origin = host.rstrip("/")
break
else:
origin = host.rstrip("/")
headers = Headers(headers)
headers.add("Access-Control-Allow-Origin", origin)
headers.add("Access-Control-Allow-Origin", self.origin(environ))
headers.add("Access-Control-Allow-Headers", "Origin, Content-Type")
headers.add("Access-Control-Allow-Credentials", "true")
headers.add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")

View File

@ -16,9 +16,9 @@ except ImportError:
from werkzeug.test import Client
from werkzeug.wrappers import Response
from isso import Isso, notify, views, core
from isso import Isso, core
from isso.utils import http
from isso.views import comment
from isso.views import comments
class Dummy:
@ -250,7 +250,7 @@ class TestComments(unittest.TestCase):
rv = loads(rv.data)
for key in comment.FIELDS:
for key in comments.API.FIELDS:
rv.pop(key)
assert not any(rv.keys())

View File

@ -5,6 +5,7 @@ from werkzeug.test import Client
from werkzeug.wrappers import Response
from isso.wsgi import CORSMiddleware
from isso.utils import origin
def hello_world(environ, start_response):
@ -14,11 +15,11 @@ def hello_world(environ, start_response):
def test_simple_CORS():
app = CORSMiddleware(hello_world, hosts=[
app = CORSMiddleware(hello_world, origin=origin([
"https://example.tld/",
"http://example.tld/",
"http://example.tld",
])
]))
client = Client(app, Response)
@ -30,19 +31,19 @@ def test_simple_CORS():
assert rv.headers["Access-Control-Allow-Methods"] == "GET, POST, PUT, DELETE"
assert rv.headers["Access-Control-Expose-Headers"] == "X-Set-Cookie"
a = client.get("/", headers={"ORIGIN": "http://example.tld/"})
a = client.get("/", headers={"ORIGIN": "http://example.tld"})
assert a.headers["Access-Control-Allow-Origin"] == "http://example.tld"
b = client.get("/", headers={"ORIGIN": "http://example.tld"})
assert a.headers["Access-Control-Allow-Origin"] == "http://example.tld"
assert b.headers["Access-Control-Allow-Origin"] == "http://example.tld"
c = client.get("/", headers={"ORIGIN": "http://foo.other/"})
assert a.headers["Access-Control-Allow-Origin"] == "http://example.tld"
c = client.get("/", headers={"ORIGIN": "http://foo.other"})
assert c.headers["Access-Control-Allow-Origin"] == "https://example.tld"
def test_preflight_CORS():
app = CORSMiddleware(hello_world, hosts=["http://example.tld"])
app = CORSMiddleware(hello_world, origin=origin(["http://example.tld"]))
client = Client(app, Response)
rv = client.open(method="OPTIONS", path="/", headers={"ORIGIN": "http://example.tld"})

View File

@ -9,7 +9,7 @@ import unittest
from werkzeug.test import Client
from werkzeug.wrappers import Response
from isso import Isso, notify, utils, core
from isso import Isso, core
from isso.utils import http
class Dummy: