refactor views and introduce an API for notifications

Keep Isso modular, not monolithic. Make it easy to integrate a
web interface or add XMPP notifications.

This refactorization includes minor bugfixes and changes:

* CORS middleware did not work properly due to wrong unit tests
* more type checks on JSON input
* new detection for origin and public url, closes #28
* new activation and delete url (no redirect for old urls, but you can
  convert the old urls: copy hash after `/activate/` (or delete) and
  open `/id/<id of comment>/activate/<hash>`
* move crypto.py to utils/

With this commit, SMTP is no longer automatically configured: add
`notify = smtp` to the `[general]` section to use SMTP.
pull/38/head
Martin Zimmermann 11 years ago
parent a442b8e0ee
commit 6e85c54a2e

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

@ -46,21 +46,26 @@ import logging
from os.path import dirname, join from os.path import dirname, join
from argparse import ArgumentParser from argparse import ArgumentParser
import misaka
from itsdangerous import URLSafeTimedSerializer from itsdangerous import URLSafeTimedSerializer
from werkzeug.routing import Map, Rule from werkzeug.routing import Map
from werkzeug.exceptions import HTTPException, InternalServerError from werkzeug.exceptions import HTTPException, InternalServerError
from werkzeug.wsgi import SharedDataMiddleware from werkzeug.wsgi import SharedDataMiddleware
from werkzeug.local import Local, LocalManager
from werkzeug.serving import run_simple, WSGIRequestHandler from werkzeug.serving import run_simple, WSGIRequestHandler
from werkzeug.contrib.fixers import ProxyFix from werkzeug.contrib.fixers import ProxyFix
from isso import db, migrate, wsgi local = Local()
local_manager = LocalManager([local])
from isso import db, migrate, wsgi, ext
from isso.core import ThreadedMixin, uWSGIMixin, Config from isso.core import ThreadedMixin, uWSGIMixin, Config
from isso.utils import parse, http, JSONRequest from isso.utils import parse, http, JSONRequest, origin
from isso.views import comments from isso.views import comments
from isso.ext.notifications import Stdout, SMTP
logging.getLogger('werkzeug').setLevel(logging.ERROR) logging.getLogger('werkzeug').setLevel(logging.ERROR)
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@ -72,20 +77,6 @@ logger = logging.getLogger("isso")
class Isso(object): class Isso(object):
salt = b"Eech7co8Ohloopo9Ol6baimi" salt = b"Eech7co8Ohloopo9Ol6baimi"
urls = Map([
Rule('/new', methods=['POST'], endpoint=comments.new),
Rule('/id/<int:id>', methods=['GET', 'PUT', 'DELETE'], endpoint=comments.single),
Rule('/id/<int:id>/like', methods=['POST'], endpoint=comments.like),
Rule('/id/<int:id>/dislike', methods=['POST'], endpoint=comments.dislike),
Rule('/', methods=['GET'], endpoint=comments.fetch),
Rule('/count', methods=['GET'], endpoint=comments.count),
Rule('/delete/<string:auth>', endpoint=comments.delete),
Rule('/activate/<string:auth>', endpoint=comments.activate),
Rule('/check-ip', endpoint=comments.checkip)
])
def __init__(self, conf): def __init__(self, conf):
@ -95,19 +86,30 @@ class Isso(object):
super(Isso, self).__init__(conf) 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): def sign(self, obj):
return self.signer.dumps(obj) return self.signer.dumps(obj)
def unsign(self, obj, max_age=None): def unsign(self, obj, max_age=None):
return self.signer.loads(obj, max_age=max_age or self.conf.getint('general', 'max-age')) 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): 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: try:
handler, values = adapter.match() handler, values = adapter.match()
@ -115,7 +117,7 @@ class Isso(object):
return e return e
else: else:
try: try:
response = handler(self, request.environ, request, **values) response = handler(request.environ, request, **values)
except HTTPException as e: except HTTPException as e:
return e return e
except Exception: except Exception:
@ -125,7 +127,6 @@ class Isso(object):
return response return response
def wsgi_app(self, environ, start_response): def wsgi_app(self, environ, start_response):
response = self.dispatch(JSONRequest(environ)) response = self.dispatch(JSONRequest(environ))
return response(environ, start_response) return response(environ, start_response)
@ -164,10 +165,11 @@ def make_app(conf=None):
wsgi.SubURI( wsgi.SubURI(
wsgi.CORSMiddleware( wsgi.CORSMiddleware(
SharedDataMiddleware( SharedDataMiddleware(
ProfilerMiddleware(isso), { ProfilerMiddleware(
'/js': join(dirname(__file__), 'js/'), local_manager.make_middleware(isso)), {
'/css': join(dirname(__file__), 'css/')}), '/js': join(dirname(__file__), 'js/'),
list(isso.conf.getiter("general", "host"))))) '/css': join(dirname(__file__), 'css/')}),
origin(isso.conf.getiter("general", "host")))))
return app return app

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

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

@ -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)

@ -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)

@ -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

@ -14,6 +14,7 @@ from string import ascii_letters, digits
from werkzeug.wrappers import Request from werkzeug.wrappers import Request
from werkzeug.exceptions import BadRequest from werkzeug.exceptions import BadRequest
import misaka
import ipaddress import ipaddress
@ -103,3 +104,23 @@ class JSONRequest(Request):
return json.loads(self.get_data(as_text=True)) return json.loads(self.get_data(as_text=True))
except ValueError: except ValueError:
raise BadRequest('Unable to read JSON request') 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

@ -20,7 +20,7 @@ class requires:
self.type = type self.type = type
def __call__(self, func): def __call__(self, func):
def dec(app, env, req, *args, **kwargs): def dec(cls, env, req, *args, **kwargs):
if self.param not in req.args: if self.param not in req.args:
raise BadRequest("missing %s query" % self.param) raise BadRequest("missing %s query" % self.param)
@ -30,6 +30,6 @@ class requires:
except TypeError: except TypeError:
raise BadRequest("invalid type for %s, expected %s" % (self.param, self.type)) raise BadRequest("invalid type for %s, expected %s" % (self.param, self.type))
return func(app, env, req, *args, **kwargs) return func(cls, env, req, *args, **kwargs)
return dec return dec

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

@ -1,8 +1,36 @@
# -*- encoding: utf-8 -*- # -*- encoding: utf-8 -*-
try:
from urllib import quote
except ImportError:
from urllib.parse import quote
from werkzeug.datastructures import Headers 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): class SubURI(object):
def __init__(self, app): def __init__(self, app):
@ -22,23 +50,15 @@ class SubURI(object):
class CORSMiddleware(object): class CORSMiddleware(object):
def __init__(self, app, hosts): def __init__(self, app, origin):
self.app = app self.app = app
self.hosts = hosts self.origin = origin
def __call__(self, environ, start_response): def __call__(self, environ, start_response):
def add_cors_headers(status, headers, exc_info=None): 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 = 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-Headers", "Origin, Content-Type")
headers.add("Access-Control-Allow-Credentials", "true") headers.add("Access-Control-Allow-Credentials", "true")
headers.add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE") headers.add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")

@ -16,7 +16,7 @@ except ImportError:
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, views, core from isso import Isso, core
from isso.utils import http from isso.utils import http
from isso.views import comments from isso.views import comments
@ -250,7 +250,7 @@ class TestComments(unittest.TestCase):
rv = loads(rv.data) rv = loads(rv.data)
for key in comments.FIELDS: for key in comments.API.FIELDS:
rv.pop(key) rv.pop(key)
assert not any(rv.keys()) assert not any(rv.keys())

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

@ -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, core from isso import Isso, core
from isso.utils import http from isso.utils import http
class Dummy: class Dummy:

Loading…
Cancel
Save