subdomain dispatcher

This commit is contained in:
Matthew Scragg 2013-10-04 16:42:45 -05:00
parent 2906b79dfc
commit 1a08aade00
8 changed files with 157 additions and 129 deletions

30
app.py
View file

@ -1,35 +1,9 @@
from gevent import monkey, pywsgi from gevent import monkey, pywsgi
monkey.patch_all() monkey.patch_all()
from realms import create_app, config from realms import config, init_db, make_app, SubdomainDispatcher
from threading import Lock
class SubdomainDispatcher(object):
def __init__(self, domain, create_app):
self.domain = domain
self.create_app = create_app
self.lock = Lock()
self.instances = {}
def get_application(self, host):
host = host.split(':')[0]
assert host.endswith(self.domain), 'Configuration error'
subdomain = host[:-len(self.domain)].rstrip('.')
with self.lock:
app = self.instances.get(subdomain)
if app is None:
app = self.create_app(subdomain)
self.instances[subdomain] = app
return app
def __call__(self, environ, start_response):
app = self.get_application(environ['HTTP_HOST'])
return app(environ, start_response)
def make_app(subdomain):
return create_app(subdomain)
if __name__ == '__main__': if __name__ == '__main__':
init_db(config.db['dbname'])
app = SubdomainDispatcher(config.domain, make_app) app = SubdomainDispatcher(config.domain, make_app)
pywsgi.WSGIServer(('', config.port), app).serve_forever() pywsgi.WSGIServer(('', config.port), app).serve_forever()

View file

@ -1,18 +1,60 @@
import logging import logging
import os import os
import time import time
from threading import Lock
from flask import Flask, request, render_template, url_for, redirect, flash, session import rethinkdb as rdb
from flask.ext.bcrypt import Bcrypt from flask import Flask, request, render_template, url_for, redirect, flash
from flask.ext.login import LoginManager, login_user, logout_user from flask.ext.login import LoginManager, login_required
from flask.ext.assets import Environment from flask.ext.assets import Environment
from recaptcha.client import captcha from recaptcha.client import captcha
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from session import RedisSessionInterface from session import RedisSessionInterface
import config import config
from wiki import Wiki from wiki import Wiki
from util import to_canonical, remove_ext, mkdir_safe, gravatar_url from util import to_canonical, remove_ext, mkdir_safe, gravatar_url
from models import Site, User, CurrentUser from models import Site, User, CurrentUser
from ratelimit import get_view_rate_limit, ratelimiter
from services import db
instances = {}
class SubdomainDispatcher(object):
def __init__(self, domain, create_app):
self.domain = domain
self.create_app = create_app
self.lock = Lock()
def get_application(self, host):
host = host.split(':')[0]
assert host.endswith(self.domain), 'Configuration error'
subdomain = host[:-len(self.domain)].rstrip('.')
with self.lock:
app = instances.get(subdomain)
if app is None:
app = self.create_app(subdomain)
instances[subdomain] = app
return app
def __call__(self, environ, start_response):
app = self.get_application(environ['HTTP_HOST'])
return app(environ, start_response)
def init_db(dbname):
if not dbname in rdb.db_list().run(db):
print "Creating DB %s" % dbname
rdb.db_create(dbname).run(db)
for tbl in ['sites', 'users', 'pages']:
if not tbl in rdb.table_list().run(db):
rdb.table_create(tbl).run(db)
s = Site()
if not s.get_by_name('_'):
s.create(name='_', repo='_')
class RegexConverter(BaseConverter): class RegexConverter(BaseConverter):
@ -21,8 +63,10 @@ class RegexConverter(BaseConverter):
self.regex = items[0] self.regex = items[0]
def redirect_url(): def redirect_url(referrer=None):
return request.args.get('next') or request.referrer or url_for('index') if not referrer:
referrer = request.referrer
return request.args.get('next') or referrer or url_for('index')
def validate_captcha(): def validate_captcha():
@ -34,6 +78,12 @@ def validate_captcha():
return response.is_valid return response.is_valid
def make_app(subdomain):
if subdomain and not Wiki.is_registered(subdomain):
return redirect("http://%s/_new/?site=%s" % (config.hostname, subdomain))
return create_app(subdomain)
def create_app(subdomain=None): def create_app(subdomain=None):
app = Flask(__name__) app = Flask(__name__)
app.config.update(config.flask) app.config.update(config.flask)
@ -43,10 +93,9 @@ def create_app(subdomain=None):
app.session_interface = RedisSessionInterface() app.session_interface = RedisSessionInterface()
app.url_map.converters['regex'] = RegexConverter app.url_map.converters['regex'] = RegexConverter
bcrypt = Bcrypt(app)
login_manager = LoginManager() login_manager = LoginManager()
login_manager.init_app(app) login_manager.init_app(app)
login_manager.login_view = 'login'
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
@ -56,60 +105,65 @@ def create_app(subdomain=None):
assets.url = app.static_url_path assets.url = app.static_url_path
assets.directory = app.static_folder assets.directory = app.static_folder
main_repo_dir = config.repos['main']
repo_dir = config.repos['dir'] repo_dir = config.repos['dir']
repo_name = subdomain if subdomain else "_"
w = Wiki(main_repo_dir) if not subdomain else Wiki(repo_dir + "/" + subdomain) w = Wiki(repo_dir + "/" + repo_name)
@app.after_request
def inject_x_rate_headers(response):
limit = get_view_rate_limit()
if limit and limit.send_x_headers:
h = response.headers
h.add('X-RateLimit-Remaining', str(limit.remaining))
h.add('X-RateLimit-Limit', str(limit.limit))
h.add('X-RateLimit-Reset', str(limit.reset))
return response
@app.template_filter('datetime') @app.template_filter('datetime')
def _jinja2_filter_datetime(ts): def _jinja2_filter_datetime(ts):
return time.strftime('%b %d, %Y %I:%M %p', time.localtime(ts)) return time.strftime('%b %d, %Y %I:%M %p', time.localtime(ts))
@app.errorhandler(404) @app.errorhandler(404)
def page_not_found(e): def page_not_found(e):
return render_template('errors/404.html'), 404 return render_template('errors/404.html'), 404
@app.errorhandler(500) @app.errorhandler(500)
def page_error(e): def page_error(e):
logging.exception(e) logging.exception(e)
return render_template('errors/500.html'), 500 return render_template('errors/500.html'), 500
@app.route("/") @app.route("/")
@ratelimiter(limit=50, per=60)
def root(): def root():
return render('home') return render('home')
#return redirect('/home') #return redirect('/home')
@app.route("/account/") @app.route("/account/")
@login_required
def account(): def account():
return render_template('account/index.html') return render_template('account/index.html')
@app.route("/_new/", methods=['GET', 'POST']) @app.route("/_new/", methods=['GET', 'POST'])
@login_required
def new_wiki(): def new_wiki():
if request.method == 'POST': if request.method == 'POST':
# TODO validate wiki name wiki_name = to_canonical(request.form['name'])
wiki_name = request.form['name']
s = Site() if Wiki.is_registered(wiki_name):
if s.get_by_name(wiki_name):
flash("Site already exists") flash("Site already exists")
return redirect(redirect_url()) return redirect(redirect_url())
else: else:
Wiki(repo_dir + "/" + wiki_name) s = Site()
s.create(name=wiki_name, repo=wiki_name)
instances.pop(wiki_name, None)
return redirect('http://%s.%s' % (wiki_name, config.hostname)) return redirect('http://%s.%s' % (wiki_name, config.hostname))
else: else:
return render_template('_new/index.html') return render_template('_new/index.html')
@app.route("/logout/") @app.route("/logout/")
def logout(): def logout():
logout_user() User.logout()
del session['user']
return redirect(url_for('root')) return redirect(url_for('root'))
@app.route("/commit/<sha>/<name>") @app.route("/commit/<sha>/<name>")
@ -122,55 +176,40 @@ def create_app(subdomain=None):
else: else:
return redirect('/create/'+cname) return redirect('/create/'+cname)
@app.route("/compare/<name>/<regex('[^.]+'):fsha><regex('\.{2,3}'):dots><regex('.+'):lsha>") @app.route("/compare/<name>/<regex('[^.]+'):fsha><regex('\.{2,3}'):dots><regex('.+'):lsha>")
def compare(name, fsha, dots, lsha): def compare(name, fsha, dots, lsha):
diff = w.compare(name, fsha, lsha) diff = w.compare(name, fsha, lsha)
return render_template('page/compare.html', name=name, diff=diff) return render_template('page/compare.html', name=name, diff=diff)
@app.route("/register", methods=['GET', 'POST']) @app.route("/register", methods=['GET', 'POST'])
def register(): def register():
if request.method == 'POST': if request.method == 'POST':
user = User() if User.register(request.form.get('username'), request.form.get('email'), request.form.get('password')):
if user.get_by_email(request.form['email']): return redirect(url_for('root'))
flash('Email is already taken') else:
return redirect('/register') # Login failed
if user.get_by_username(request.form['username']): return redirect(url_for('register'))
flash('Username is already taken')
return redirect('/register')
email = request.form['email'].lower()
# Create user and login
u = User.create(email=email,
username=request.form['username'],
password=bcrypt.generate_password_hash(request.form['password']),
avatar=gravatar_url(email))
login_user(CurrentUser(u.id))
return redirect("/")
else: else:
return render_template('account/register.html') return render_template('account/register.html')
@app.route("/login", methods=['GET', 'POST']) @app.route("/login", methods=['GET', 'POST'])
def login(): def login():
if request.method == 'POST': if request.method == 'POST':
if User.auth(request.form['email'], request.form['password']): if User.auth(request.form['email'], request.form['password']):
return redirect("/") return redirect(redirect_url(referrer=url_for('root')))
else: else:
flash("Email or Password invalid") flash("Email or Password invalid")
return redirect("/login") return redirect("/login")
else: else:
return render_template('account/login.html') return render_template('account/login.html')
@app.route("/history/<name>") @app.route("/history/<name>")
def history(name): def history(name):
history = w.get_history(name) history = w.get_history(name)
return render_template('page/history.html', name=name, history=history) return render_template('page/history.html', name=name, history=history)
@app.route("/edit/<name>", methods=['GET', 'POST']) @app.route("/edit/<name>", methods=['GET', 'POST'])
@login_required
def edit(name): def edit(name):
data = w.get_page(name) data = w.get_page(name)
cname = to_canonical(name) cname = to_canonical(name)
@ -188,14 +227,14 @@ def create_app(subdomain=None):
else: else:
return redirect('/create/'+cname) return redirect('/create/'+cname)
@app.route("/delete/<name>", methods=['POST']) @app.route("/delete/<name>", methods=['POST'])
@login_required
def delete(name): def delete(name):
pass pass
@app.route("/create/", methods=['GET', 'POST']) @app.route("/create/", methods=['GET', 'POST'])
@app.route("/create/<name>", methods=['GET', 'POST']) @app.route("/create/<name>", methods=['GET', 'POST'])
@login_required
def create(name=None): def create(name=None):
cname = "" cname = ""
if name: if name:

View file

@ -1,27 +1,13 @@
import rethinkdb as rdb import rethinkdb as rdb
import bcrypt import bcrypt
import redis from flask import session, flash
from flask import session from flask.ext.login import login_user, logout_user
from flask.ext.login import login_user
from rethinkORM import RethinkModel from rethinkORM import RethinkModel
from realms import config from util import gravatar_url
from services import db
# Default DB connection
conn = rdb.connect(config.db['host'], config.db['port'], db=config.db['dbname'])
# Default Cache connection
cache = redis.StrictRedis(host=config.cache['host'], port=config.cache['port'])
def init_db(): def to_dict(cur, first):
if not config.db['dbname'] in rdb.db_list().run(conn) and config.ENV is not 'PROD':
# Create default db and repo
print "Creating DB %s" % config.db['dbname']
rdb.db_create(config.db['dbname']).run(conn)
for tbl in ['sites', 'users', 'pages']:
rdb.table_create(tbl).run(conn)
def to_dict(cur, first=False):
ret = [] ret = []
for row in cur: for row in cur:
ret.append(row) ret.append(row)
@ -33,9 +19,11 @@ def to_dict(cur, first=False):
class BaseModel(RethinkModel): class BaseModel(RethinkModel):
_conn = db
def __init__(self, **kwargs): def __init__(self, **kwargs):
if not kwargs.get('conn'): if not kwargs.get('conn'):
kwargs['conn'] = conn kwargs['conn'] = db
super(BaseModel, self).__init__(**kwargs) super(BaseModel, self).__init__(**kwargs)
@classmethod @classmethod
@ -66,7 +54,7 @@ class CurrentUser():
return self.id return self.id
def is_active(self): def is_active(self):
return True return True if self.id else False
def is_anonymous(self): def is_anonymous(self):
return False if self.id else True return False if self.id else True
@ -100,8 +88,36 @@ class User(BaseModel):
return False return False
if bcrypt.checkpw(password, data['password']): if bcrypt.checkpw(password, data['password']):
login_user(CurrentUser(data['id'])) cls.login(data['id'], data)
session['user'] = data
return True return True
else: else:
return False return False
@classmethod
def register(cls, username, email, password):
user = User()
email = email.lower()
if user.get_by_email(email):
flash('Email is already taken')
return False
if user.get_by_username(username):
flash('Username is already taken')
return False
# Create user and login
u = User.create(email=email,
username=username,
password=bcrypt.hashpw(password, bcrypt.gensalt(10)),
avatar=gravatar_url(email))
User.login(u.id, user.get_one(u.id, 'id'))
@classmethod
def login(cls, id, data=None):
login_user(CurrentUser(id), True)
session['user'] = data
@classmethod
def logout(cls):
logout_user()
session.pop('user', None)

View file

@ -1,8 +1,7 @@
import time import time
from functools import update_wrapper from functools import update_wrapper
from flask import request, g from flask import request, g
from realms import app from services import cache
from models import cache
class RateLimit(object): class RateLimit(object):
@ -28,10 +27,10 @@ def get_view_rate_limit():
def on_over_limit(limit): def on_over_limit(limit):
return 'You hit the rate limit', 400 return 'Slow it down', 400
def ratelimit(limit, per=300, send_x_headers=True, def ratelimiter(limit, per=300, send_x_headers=True,
over_limit=on_over_limit, over_limit=on_over_limit,
scope_func=lambda: request.remote_addr, scope_func=lambda: request.remote_addr,
key_func=lambda: request.endpoint): key_func=lambda: request.endpoint):
@ -45,14 +44,3 @@ def ratelimit(limit, per=300, send_x_headers=True,
return f(*args, **kwargs) return f(*args, **kwargs)
return update_wrapper(rate_limited, f) return update_wrapper(rate_limited, f)
return decorator return decorator
@app.after_request
def inject_x_rate_headers(response):
limit = get_view_rate_limit()
if limit and limit.send_x_headers:
h = response.headers
h.add('X-RateLimit-Remaining', str(limit.remaining))
h.add('X-RateLimit-Limit', str(limit.limit))
h.add('X-RateLimit-Reset', str(limit.reset))
return response

11
realms/services.py Normal file
View file

@ -0,0 +1,11 @@
import rethinkdb as rdb
import redis
import config
# Default DB connection
db = rdb.connect(config.db['host'], config.db['port'], db=config.db['dbname'])
# Default Cache connection
cache = redis.StrictRedis(host=config.cache['host'], port=config.cache['port'])

View file

@ -7,7 +7,7 @@
<div class="form-group"> <div class="form-group">
<label for="wiki" class="control-label">Site Name</label> <label for="wiki" class="control-label">Site Name</label>
<div class="input-group"> <div class="input-group">
<input id="wiki" name="name" type="text" class="form-control" /> <input id="wiki" name="name" type="text" class="form-control" value="{{ request.args.get('site') }}" />
<span class="input-group-addon">.realms.io</span> <span class="input-group-addon">.realms.io</span>
</div> </div>
</div> </div>

View file

@ -3,18 +3,12 @@
<h2>Account</h2> <h2>Account</h2>
<form method="POST" role="form" class="form-horizontal"> <form method="POST" role="form">
<div class="form-group"> <div class="form-group">
<label for="email" class="col-md-2 control-label">Email</label> <label for="email" class="control-label">Email</label>
<div class="col-md-10">
<input id="email" type="text" class="form-control" value="{{ session['user']['email'] }}" /> <input id="email" type="text" class="form-control" value="{{ session['user']['email'] }}" />
</div> </div>
</div>
<div class="form-group">
<div class="col-md-offset-2 col-md-10">
<input type="submit" class="btn btn-primary" value="Save"> <input type="submit" class="btn btn-primary" value="Save">
</div>
</div>
</form> </form>
{% endblock %} {% endblock %}

View file

@ -4,8 +4,10 @@ from lxml.html.clean import clean_html
import ghdiff import ghdiff
from gittle import Gittle from gittle import Gittle
from dulwich.repo import NotGitRepository
from util import to_canonical from util import to_canonical
from models import Site
class MyGittle(Gittle): class MyGittle(Gittle):
@ -54,13 +56,17 @@ class Wiki():
def __init__(self, path): def __init__(self, path):
try: try:
self.repo = MyGittle.init(path)
except OSError:
# Repo already exists
self.repo = MyGittle(path) self.repo = MyGittle(path)
except NotGitRepository:
self.repo = MyGittle.init(path)
self.path = path self.path = path
@staticmethod
def is_registered(name):
s = Site()
return True if s.get_by_name(name) else False
def write_page(self, name, content, message=None, create=False, username=None, email=None): def write_page(self, name, content, message=None, create=False, username=None, email=None):
content = clean_html(content) content = clean_html(content)
filename = self.cname_to_filename(to_canonical(name)) filename = self.cname_to_filename(to_canonical(name))