rewrite using NIH

pull/16/head
posativ 12 years ago
parent 440787ff67
commit a4514e1f91

@ -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/<re(".+"):path>/', 'comment.get', ['GET']),
url('/1.0/<re(".+"):path>/new', 'comment.create', ['POST']),
url('/1.0/<re(".+"):path>/<int:id>', 'comment.get', ['GET']),
url('/1.0/<re(".+"):path>/<int:id>', 'comment.modify', ['PUT', 'DELETE']),
url('/1.0/<re(".+"):path>/<int:id>/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 '<h1>' + self.status(500) + '</h1>'
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()

@ -3,12 +3,12 @@
# Copyright 2012, Martin Zimmermann <info@posativ.org>. 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'}

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

@ -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('<span class="note">Kommentar muss noch freigeschaltet werden</span>');
}
if (read('session-' + path + '-' + post['id'])) {
if (read(path + '-' + post['id'])) {
$('#isso_' + post['id'] + '> footer > a:first-child')
.after('<a class="delete" href="#">Löschen</a>')
.after('<a class="edit" href="#">Bearbeiten</a>');
// 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) {

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

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

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

@ -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'],

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

Loading…
Cancel
Save