# -*- encoding: utf-8 -*- import cgi import json import time import hashlib import logging import sqlite3 from itsdangerous import SignatureExpired, BadSignature from werkzeug.wrappers import Response from werkzeug.exceptions import abort, BadRequest 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): try: data = json.loads(request.get_data().decode('utf-8')) except ValueError: return Response("No JSON object could be decoded", 400) for field in set(data.keys()) - set(['text', 'author', 'website', 'email', 'parent']): data.pop(field) if not data.get("text"): return Response("No text given.", 400) if "id" in data and not isinstance(data["id"], int): return Response("Parent ID must be an integer.") 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 sqlite3.Error: logging.exception('uncaught SQLite3 exception') abort(400) except db.IssoDBException: abort(403) 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.get('email') or rv['remote_addr'], app.salt, 1000, 6)) for key in set(rv.keys()) - FIELDS: rv.pop(key) # success! logger.info('comment created: %s', json.dumps(rv)) resp = Response(json.dumps(rv), 202 if rv["mode"] == 2 else 201, content_type='application/json') resp.set_cookie(str(rv["id"]), app.sign([rv["id"], checksum]), max_age=app.conf.getint('general', 'max-age')) return resp def single(app, environ, request, id): if request.method == 'GET': rv = app.db.comments.get(id) if rv is None: abort(404) 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): abort(403) if rv[0] != id: abort(403) # 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(): abort(403) if request.method == 'PUT': try: data = json.loads(request.get_data().decode('utf-8')) except ValueError: return Response("No JSON object could be decoded", 400) if data.get("text") is not None and len(data['text']) < 3: return Response("No text given.", 400) 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"]) resp = Response(json.dumps(rv), 200, content_type='application/json') resp.set_cookie(str(rv["id"]), app.sign([rv["id"], checksum]), max_age=app.conf.getint('general', 'max-age')) return resp if request.method == 'DELETE': rv = app.db.comments.delete(id) if rv: for key in set(rv.keys()) - FIELDS: rv.pop(key) logger.info('comment %i deleted', id) resp = Response(json.dumps(rv), 200, content_type='application/json') resp.delete_cookie(str(id), path='/') return resp @requires(str, 'uri') def fetch(app, environ, request, uri): rv = list(app.db.comments.fetch(uri)) if not rv: abort(404) for item in rv: item['hash'] = str(pbkdf2(item['email'] or item['remote_addr'], app.salt, 1000, 6)) 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: abort(404) 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): abort(403) 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): abort(403) 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)