From a4514e1f9111a1dd0ba222884c56b012e5b2f8db Mon Sep 17 00:00:00 2001 From: posativ Date: Sun, 16 Dec 2012 00:13:00 +0100 Subject: [PATCH] rewrite using NIH --- isso/__init__.py | 148 ++++++++++++++++++++++++-------------- isso/admin.py | 22 +++--- isso/comment.py | 57 ++++++++------- isso/js/isso.js | 17 +++-- isso/templates/admin.mako | 2 +- isso/utils.py | 11 +-- isso/wsgi.py | 121 +++++++++++++++++++++++++++++++ setup.py | 2 +- specs/test_comment.py | 4 +- 9 files changed, 274 insertions(+), 110 deletions(-) create mode 100644 isso/wsgi.py diff --git a/isso/__init__.py b/isso/__init__.py index 0883ebb..f246818 100644 --- a/isso/__init__.py +++ b/isso/__init__.py @@ -20,51 +20,29 @@ # # Isso – a lightweight Disqus alternative -__version__ = '0.2' +__version__ = '0.3' import sys; reload(sys) sys.setdefaultencoding('utf-8') # we only support UTF-8 and python 2.X :-) import io import json +import traceback -from os.path import join, dirname +from os.path import dirname from optparse import OptionParser, make_option, SUPPRESS_HELP from itsdangerous import URLSafeTimedSerializer -from werkzeug.wsgi import SharedDataMiddleware -from werkzeug.routing import Map, Rule -from werkzeug.serving import run_simple -from werkzeug.wrappers import Request, Response -from werkzeug.exceptions import HTTPException, NotFound, InternalServerError - -from isso import admin, comment, db, migrate -from isso.utils import determine, import_object, RegexConverter, IssoEncoder +from isso import admin, comment, db, migrate, wsgi +from isso.utils import determine, import_object, IssoEncoder # override default json :func:`dumps`. _dumps = json.dumps setattr(json, 'dumps', lambda obj, **kw: _dumps(obj, cls=IssoEncoder, **kw)) -# yep. lazy. -url = lambda path, endpoint, methods: Rule(path, endpoint=endpoint, methods=methods) - -url_map = Map([ - # moderation panel - url('/', 'admin.login', ['GET', 'POST']), - url('/admin/', 'admin.index', ['GET', 'POST']), - - # comment API, note that the client side quotes the URL, but this is - # actually unnecessary. PEP 333 aka WSGI always unquotes PATH_INFO. - url('/1.0//', 'comment.get', ['GET']), - url('/1.0//new', 'comment.create', ['POST']), - url('/1.0//', 'comment.get', ['GET']), - url('/1.0//', 'comment.modify', ['PUT', 'DELETE']), - url('/1.0///approve', 'comment.approve', ['PUT']) -], converters={'re': RegexConverter}) - -class Isso: +class Isso(object): PRODUCTION = True SECRET = 'secret' @@ -75,6 +53,12 @@ class Isso: HOST = 'http://localhost:8000/' MAX_AGE = 15 * 60 + HTTP_STATUS_CODES = { + 200: 'Ok', 201: 'Created', 202: 'Accepted', 301: 'Moved Permanently', + 400: 'Bad Request', 404: 'Not Found', 403: 'Forbidden', + 500: 'Internal Server Error', + } + def __init__(self, conf): self.__dict__.update(dict((k, v) for k, v in conf.iteritems() if k.isupper())) @@ -85,6 +69,22 @@ class Isso: self.db = db.SQLite(self) self.markup = import_object(conf.get('MARKUP', 'isso.markup.Markdown'))(conf) + self.adapter = map( + lambda r: (wsgi.Rule(r[0]), r[1], r[2] if isinstance(r[2], list) else [r[2]]), [ + + # moderation panel + ('/', admin.login, ['GET', 'POST']), + ('/admin/', admin.index, ['GET', 'POST']), + + # comment API, note that the client side quotes the URL, but this is + # actually unnecessary. PEP 333 aka WSGI always unquotes PATH_INFO. + ('/1.0/<(.+?):path>/new', comment.create, 'POST'), + ('/1.0/<(.+?):path>/<(int):id>', comment.get, 'GET'), + ('/1.0/<(.+?):path>/<(int):id>', comment.modify, ['PUT', 'DELETE']), + ('/1.0/<(.+?):path>/<(int):id>/approve', comment.approve, 'PUT'), + + ('/1.0/<(.+?):path>', comment.get, 'GET'), + ]) def sign(self, obj): return self.signer.dumps(obj) @@ -92,27 +92,71 @@ class Isso: def unsign(self, obj): return self.signer.loads(obj, max_age=self.MAX_AGE) - def dispatch(self, request, start_response): - adapter = url_map.bind_to_environ(request.environ) + def status(self, code): + return '%i %s' % (code, self.HTTP_STATUS_CODES[code]) + + def dispatch(self, path, method): + + for rule, handler, methods in self.adapter: + if isinstance(methods, basestring): + methods = [methods, ] + if method not in methods: + continue + m = rule.match(path) + if m is not None: + return handler, m + else: + return (lambda app, environ, request, **kw: (404, 'Not Found', {}), {}) + + def wsgi(self, environ, start_response): + try: - endpoint, values = adapter.match() - module, function = endpoint.split('.', 1) - handler = getattr(globals()[module], function) - return handler(self, request.environ, request, **values) - except NotFound, e: - return Response('Not Found', 404) - except HTTPException, e: - return e - except InternalServerError, e: - return Response(e, 500) - - def wsgi_app(self, environ, start_response): - request = Request(environ) - response = self.dispatch(request, start_response) - return response(environ, start_response) + request = wsgi.Request(environ) + handler, kwargs = self.dispatch(environ['PATH_INFO'], request.method) + code, body, headers = handler(self, environ, request, **kwargs) + + if code == 404: + try: + code, body, headers = wsgi.sendfile(environ['PATH_INFO'], dirname(__file__)) + except (IOError, OSError): + pass + + if request == 'HEAD': + body = '' + + start_response(self.status(code), headers.items()) + return body + + except (KeyboardInterrupt, SystemExit): + raise + except Exception: + traceback.print_exc(file=sys.stderr) + headers = [('Content-Type', 'text/html; charset=utf-8')] + start_response(self.status(500), headers) + return '

' + self.status(500) + '

' def __call__(self, environ, start_response): - return self.wsgi_app(environ, start_response) + return self.wsgi(environ, start_response) + + +class ReverseProxied(object): + + def __init__(self, app, prefix=None): + self.app = app + self.prefix = prefix if prefix is not None else '' + + def __call__(self, environ, start_response): + script_name = environ.get('HTTP_X_SCRIPT_NAME', self.prefix) + if script_name: + environ['SCRIPT_NAME'] = script_name + path_info = environ['PATH_INFO'] + if path_info.startswith(script_name): + environ['PATH_INFO'] = path_info[len(script_name):] + + scheme = environ.get('HTTP_X_SCHEME', '') + if scheme: + environ['wsgi.url_scheme'] = scheme + return self.app(environ, start_response) def main(): @@ -122,7 +166,7 @@ def main(): make_option("--sqlite", dest="sqlite", metavar='FILE', default="/tmp/sqlite.db", help="use SQLite3 database"), make_option("--port", dest="port", default=8000, help="webserver port"), - make_option("--test", dest="production", action="store_false", default=True, + make_option("--debug", dest="production", action="store_false", default=True, help=SUPPRESS_HELP), ] @@ -133,7 +177,7 @@ def main(): print 'isso', __version__ sys.exit(0) - app = Isso({'SQLITE': options.sqlite, 'PRODUCTION': options.production}) + app = Isso({'SQLITE': options.sqlite, 'PRODUCTION': options.production, 'MODERATION': False}) if len(args) > 0 and args[0] == 'import': if len(args) < 2: @@ -142,8 +186,8 @@ def main(): with io.open(args[1], encoding='utf-8') as fp: migrate.disqus(app.db, fp.read()) + else: - app = SharedDataMiddleware(app, { - '/static': join(dirname(__file__), 'static'), - '/js': join(dirname(__file__), 'js')}) - run_simple('127.0.0.1', 8000, app, use_reloader=True) + from wsgiref.simple_server import make_server + httpd = make_server('127.0.0.1', 8080, app) + httpd.serve_forever() diff --git a/isso/admin.py b/isso/admin.py index f820d1f..2fd7374 100644 --- a/isso/admin.py +++ b/isso/admin.py @@ -3,12 +3,12 @@ # Copyright 2012, Martin Zimmermann . All rights reserved. # License: BSD Style, 2 clauses. see isso/__init__.py -from werkzeug.utils import redirect -from werkzeug.wrappers import Response - from mako.lookup import TemplateLookup from itsdangerous import SignatureExpired, BadSignature +from isso.wsgi import setcookie + + mako = TemplateLookup(directories=['isso/templates'], input_encoding='utf-8') render = lambda template, **context: mako.get_template(template).render_unicode(**context) @@ -16,12 +16,14 @@ render = lambda template, **context: mako.get_template(template).render_unicode( def login(app, environ, request): if request.method == 'POST': - if request.form.get('secret') == app.SECRET: - rdr = redirect('/admin/', 301) - rdr.set_cookie('session-admin', app.signer.dumps('*'), max_age=app.MAX_AGE) - return rdr + if request.form.getfirst('secret') == app.SECRET: + return 301, '', { + 'Location': '/admin/', + 'Set-Cookie': setcookie('session-admin', app.signer.dumps('*'), + max_age=app.MAX_AGE, path='/') + } - return Response(render('login.mako'), content_type='text/html') + return 200, render('login.mako').encode('utf-8'), {'Content-Type': 'text/html'} def index(app, environ, request): @@ -29,7 +31,7 @@ def index(app, environ, request): try: app.unsign(request.cookies.get('session-admin', '')) except (SignatureExpired, BadSignature): - return redirect('/') + return 301, '', {'Location': '/'} ctx = {'app': app, 'request': request} - return Response(render('admin.mako', **ctx), content_type='text/html') + return 200, render('admin.mako', **ctx).encode('utf-8'), {'Content-Type': 'text/html'} diff --git a/isso/comment.py b/isso/comment.py index 0feb230..17ccc1b 100644 --- a/isso/comment.py +++ b/isso/comment.py @@ -6,50 +6,48 @@ import cgi import urllib -from werkzeug.wrappers import Response -from werkzeug.exceptions import abort - from itsdangerous import SignatureExpired, BadSignature -from isso import json, models, utils +from isso import json, models, utils, wsgi def create(app, environ, request, path): if app.PRODUCTION and not utils.urlexists(app.HOST, '/' + path): - return abort(404) + return 400, 'URL does not exist', {} try: comment = models.Comment.fromjson(request.data) - except ValueError: - return abort(400) + except ValueError as e: + return 400, unicode(e), {} for attr in 'author', 'email', 'website': if getattr(comment, attr) is not None: try: setattr(comment, attr, cgi.escape(getattr(comment, attr))) except AttributeError: - abort(400) + return 400, '', {} try: rv = app.db.add(path, comment) except ValueError: - return abort(400) + return 400, '', {} md5 = rv.md5 rv.text = app.markup.convert(rv.text) - response = Response(json.dumps(rv), 202 if rv.pending else 201, content_type='application/json') - response.set_cookie('session-%s-%s' % (urllib.quote(path, ''), rv.id), - app.signer.dumps([path, rv.id, md5]), max_age=app.MAX_AGE) - return response + return 202 if rv.pending else 201, json.dumps(rv), { + 'Content-Type': 'application/json', + 'Set-Cookie': wsgi.setcookie('%s-%s' % (path, rv.id), + app.sign([path, rv.id, md5]), max_age=app.MAX_AGE, path='/') + } def get(app, environ, request, path, id=None): rv = list(app.db.retrieve(path)) if id is None else app.db.get(path, id) if not rv: - abort(404) + return 400, '', {} if request.args.get('plain', '0') == '0': if isinstance(rv, list): @@ -58,46 +56,47 @@ def get(app, environ, request, path, id=None): else: rv.text = app.markup.convert(rv.text) - return Response(json.dumps(rv), 200, content_type='application/json') + return 200, json.dumps(rv), {'Content-Type': 'application/json'} def modify(app, environ, request, path, id): try: - rv = app.unsign(request.cookies.get('session-%s-%s' % (urllib.quote(path, ''), id), '')) - except (SignatureExpired, BadSignature): + rv = app.unsign(request.cookies.get('%s-%s' % (urllib.quote(path, ''), id), '')) + except (SignatureExpired, BadSignature) as e: try: - rv = app.unsign(request.cookies.get('session-admin', '')) + rv = app.unsign(request.cookies.get('admin', '')) except (SignatureExpired, BadSignature): - return abort(403) + return 403, '', {} # verify checksum, mallory might skip cookie deletion when he deletes a comment if not (rv == '*' or rv[0:2] == [path, id] or app.db.get(path, id).md5 != rv[2]): - abort(403) + return 403, '', {} if request.method == 'PUT': try: rv = app.db.update(path, id, models.Comment.fromjson(request.data)) rv.text = app.markup.convert(rv.text) - return Response(json.dumps(rv), 200, content_type='application/json') + return 200, json.dumps(rv), {'Content-Type': 'application/json'} except ValueError as e: - return Response(unicode(e), 400) + return 400, unicode(e), {} if request.method == 'DELETE': rv = app.db.delete(path, id) - response = Response(json.dumps(rv), 200, content_type='application/json') - response.delete_cookie('session-%s-%s' % (urllib.quote(path, ''), id)) - return response + return 200, json.dumps(rv), { + 'Content-Type': 'application/json', + 'Set-Cookie': wsgi.setcookie(path + '-' + str(id), 'deleted', max_age=0, path='/') + } def approve(app, environ, request, path, id): try: - if app.unsign(request.cookies.get('session-admin', '')) != '*': - abort(403) + if app.unsign(request.cookies.get('admin', '')) != '*': + return 403, '', {} except (SignatureExpired, BadSignature): - abort(403) + return 403, '', {} app.db.activate(path, id) - return Response(json.dumps(app.db.get(path, id)), 200, content_type='application/json') + return 200, json.dumps(app.db.get(path, id)), {'Content-Type': 'application/json'} diff --git a/isso/js/isso.js b/isso/js/isso.js index 0f9d1fc..cfea3ae 100644 --- a/isso/js/isso.js +++ b/isso/js/isso.js @@ -16,6 +16,9 @@ * zfill(argument, i): zero fill `argument` with `i` zeros */ + // var prefix = "/comments"; + var prefix = ""; + function read(cookie){ return(document.cookie.match('(^|; )' + cookie + '=([^;]*)') || 0)[2] @@ -60,7 +63,7 @@ function create(data, func) { return; } - $.ajax('POST', '/1.0/' + encodeURIComponent(window.location.pathname) + '/new', + $.ajax('POST', prefix + '/1.0/' + encodeURIComponent(window.location.pathname) + '/new', JSON.stringify(data), {'Content-Type': 'application/json'}).then(func); }; @@ -70,7 +73,7 @@ function modify(id, data, func) { return; } - $.ajax('PUT', '/1.0/' + encodeURIComponent(window.location.pathname) + '/' + id, + $.ajax('PUT', prefix + '/1.0/' + encodeURIComponent(window.location.pathname) + '/' + id, JSON.stringify(data), {'Content-Type': 'application/json'}).then(func) }; @@ -169,14 +172,15 @@ function insert(post) { .append('Kommentar muss noch freigeschaltet werden'); } - if (read('session-' + path + '-' + post['id'])) { + if (read(path + '-' + post['id'])) { $('#isso_' + post['id'] + '> footer > a:first-child') .after('Löschen') .after('Bearbeiten'); // DELETE $('#isso_' + post['id'] + ' > footer .delete').on('click', function(event) { - $.ajax('DELETE', '/1.0/' + path + '/' + post['id']).then(function(status, rv) { + $.ajax('DELETE', prefix + '/1.0/' + path + '/' + post['id']) + .then(function(status, rv) { // XXX comment might not actually deleted $('#isso_' + post['id']).remove(); }); @@ -188,7 +192,7 @@ function insert(post) { if ($('#issoform_' + post['id']).length == 0) { - $.ajax('GET', '/1.0/' + path + '/' + post['id'], {'plain': '1'}) + $.ajax('GET', prefix + '/1.0/' + path + '/' + post['id'], {'plain': '1'}) .then(function(status, rv) { rv = JSON.parse(rv); form(post['id'], @@ -285,7 +289,8 @@ function initialize(thread) { function fetch(thread) { - $.ajax('GET', '/1.0/' + encodeURIComponent(window.location.pathname) + '/', + console.log(window.location.pathname); + $.ajax('GET', prefix + '/1.0/' + encodeURIComponent(window.location.pathname), {}, {'Content-Type': 'application/json'}).then(function(status, rv) { if (status != 200) { diff --git a/isso/templates/admin.mako b/isso/templates/admin.mako index d1cb2a5..973f572 100644 --- a/isso/templates/admin.mako +++ b/isso/templates/admin.mako @@ -17,7 +17,7 @@ def get(name, convert): limit = request.args.get(name) - return convert(limit) if limit is not None else None + return convert(limit[0]) if limit is not None else None %> <%block name="title"> diff --git a/isso/utils.py b/isso/utils.py index 8fcbc16..949017a 100644 --- a/isso/utils.py +++ b/isso/utils.py @@ -6,10 +6,9 @@ import json import socket import httplib -import urlparse import contextlib -import werkzeug.routing +from urlparse import urlparse from isso.models import Comment @@ -23,12 +22,6 @@ class IssoEncoder(json.JSONEncoder): return json.JSONEncoder.default(self, obj) -class RegexConverter(werkzeug.routing.BaseConverter): - def __init__(self, url_map, *items): - super(RegexConverter, self).__init__(url_map) - self.regex = items[0] - - def urlexists(host, path): with contextlib.closing(httplib.HTTPConnection(host)) as con: try: @@ -43,7 +36,7 @@ def determine(host): if not host.startswith(('http://', 'https://')): host = 'http://' + host - rv = urlparse.urlparse(host) + rv = urlparse(host) return (rv.netloc + ':443') if rv.scheme == 'https' else rv.netloc diff --git a/isso/wsgi.py b/isso/wsgi.py new file mode 100644 index 0000000..0b91b14 --- /dev/null +++ b/isso/wsgi.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +import io +import os +import re +import cgi +import tempfile +import urlparse +import mimetypes + +from Cookie import SimpleCookie +from urllib import quote + + +class Request(object): + + def __init__(self, environ): + + # from bottle.py Copyright 2012, Marcel Hellkamp, License: MIT. + maxread = max(0, int(environ['CONTENT_LENGTH'] or '0')) + stream = environ['wsgi.input'] + body = tempfile.TemporaryFile(mode='w+b') + while maxread > 0: + part = stream.read(maxread) + if not part: + break + body.write(part) + maxread -= len(part) + + self.body = body + self.body.seek(0) + self.environ = environ + self.query_string = self.environ['QUERY_STRING'] + + @property + def data(self): + self.body.seek(0) + return self.body.read() + + @property + def method(self): + return self.environ['REQUEST_METHOD'] + + @property + def args(self): + return urlparse.parse_qs(self.environ['QUERY_STRING']) + + @property + def form(self): + if self.environ['CONTENT_TYPE'] == 'application/x-www-form-urlencoded': + return cgi.FieldStorage(fp=self.body, environ=self.environ) + return dict() + + @property + def cookies(self): + cookie = SimpleCookie(self.environ.get('HTTP_COOKIE', '')) + return {v.key: v.value for v in cookie.values()} + + +class Rule(str): + + repl = {'int': r'[0-9]+', 'float': r'\-?[0-9]+\.[0-9]+'} + + def __init__(self, string): + + first, last, rv = 0, -1, [] + f = lambda m: '(?P<%s>%s)' % (m.group(2), self.repl.get(m.group(1), m.group(1))) + + for i, c in enumerate(string): + if c == '<': + first = i + rv.append(re.escape(string[last+1:first])) + if c == '>': + last = i + if last > first: + rv.append(re.sub(r'<\(([^:]+)\):(\w+)>', f, string[first:last+1])) + first = last + + rv.append(re.escape(string[last+1:])) + self.rule = re.compile('^' + ''.join(rv) + '$') + + def match(self, obj): + + match = re.match(self.rule, obj) + if not match: return + + kwargs = match.groupdict() + for key, value in kwargs.items(): + for type in int, float: + try: + kwargs[key] = type(value) + break + except ValueError: + pass + + return kwargs + + +def sendfile(filename, root): + + headers = {} + root = os.path.abspath(root) + os.sep + filename = os.path.abspath(os.path.join(root, filename.strip('/\\'))) + + if not filename.startswith(root): + return 403, '', headers + + mimetype, encoding = mimetypes.guess_type(filename) + if mimetype: headers['Content-Type'] = mimetype + if encoding: headers['Content-Encoding'] = encoding + + stats = os.stat(filename) + headers['Content-Length'] = str(stats.st_size) + + return 200, io.open(filename, 'rb'), headers + + +def setcookie(name, value, **kwargs): + return '; '.join([quote(name, '') + '=' + quote(value, '')] + + [k.replace('_', '-') + '=' + str(v) for k, v in kwargs.iteritems()]) diff --git a/setup.py b/setup.py index 8a02e44..50bab2d 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ setup( "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7" ], - install_requires=['werkzeug', 'mako', 'itsdangerous'], + install_requires=['mako', 'itsdangerous'], entry_points={ 'console_scripts': ['isso = isso:main'], diff --git a/specs/test_comment.py b/specs/test_comment.py index e0ac17d..898d41a 100644 --- a/specs/test_comment.py +++ b/specs/test_comment.py @@ -113,8 +113,8 @@ class TestComments(unittest.TestCase): data=json.dumps(comment(text='...'))).status_code == 201 for path in paths: - assert self.get('/1.0/' + path) - assert self.get('/1.0/' + path + '/1') + assert self.get('/1.0/' + path + '/').status_code == 200 + assert self.get('/1.0/' + path + '/1').status_code == 200 def testDeleteAndCreateByDifferentUsersButSamePostId(self):