This commit is contained in:
Matthew Scragg 2014-08-30 10:06:12 -05:00
parent b02d3db684
commit 86f0549e44
24 changed files with 710 additions and 398 deletions

1
Vagrantfile vendored
View file

@ -11,6 +11,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.synced_folder "srv/", "/srv/" config.vm.synced_folder "srv/", "/srv/"
config.vm.synced_folder ".", "/home/deploy/realms" config.vm.synced_folder ".", "/home/deploy/realms"
config.vm.synced_folder "~/.virtualenvs", "/home/deploy/virtualenvs"
config.vm.provision :salt do |salt| config.vm.provision :salt do |salt|
salt.minion_config = "srv/minion" salt.minion_config = "srv/minion"
salt.run_highstate = true salt.run_highstate = true

View file

@ -1,9 +1,15 @@
from gevent import wsgi from gevent import wsgi
from realms import config, app, manager from realms import config, app, manager
from flask.ext.script import Server
manager.add_command("runserver", Server(host="0.0.0.0", port=config.PORT))
@manager.command @manager.command
def server(): def run():
"""
Run production ready server
"""
print "Server started. Env: %s Port: %s" % (config.ENV, config.PORT) print "Server started. Env: %s Port: %s" % (config.ENV, config.PORT)
wsgi.WSGIServer(('', int(config.PORT)), app).serve_forever() wsgi.WSGIServer(('', int(config.PORT)), app).serve_forever()

View file

@ -20,18 +20,18 @@ import sys
import json import json
import httplib import httplib
import traceback import traceback
from flask import Flask, request, render_template, url_for, redirect, session, flash, g from flask import Flask, request, render_template, url_for, redirect, g
from flask.ctx import _AppCtxGlobals from flask.ctx import _AppCtxGlobals
from flask.ext.cache import Cache
from flask.ext.script import Manager from flask.ext.script import Manager
from flask.ext.login import LoginManager from flask.ext.login import LoginManager, current_user
from flask.ext.sqlalchemy import SQLAlchemy
from flask.ext.assets import Environment, Bundle
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from werkzeug.utils import cached_property from werkzeug.utils import cached_property
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from realms import config from realms import config
from realms.lib.ratelimit import get_view_rate_limit, ratelimiter
from realms.lib.session import RedisSessionInterface
from realms.lib.wiki import Wiki
from realms.lib.util import to_canonical, remove_ext, mkdir_safe, gravatar_url, to_dict from realms.lib.util import to_canonical, remove_ext, mkdir_safe, gravatar_url, to_dict
@ -39,11 +39,7 @@ class AppCtxGlobals(_AppCtxGlobals):
@cached_property @cached_property
def current_user(self): def current_user(self):
return session.get('user') if session.get('user') else {'username': 'Anon'} return current_user
@cached_property
def current_wiki(self):
return Wiki(config.WIKI_PATH)
class Application(Flask): class Application(Flask):
@ -68,8 +64,8 @@ class Application(Flask):
return super(Application, self).__call__(environ, start_response) return super(Application, self).__call__(environ, start_response)
def discover(self): def discover(self):
IMPORT_NAME = 'realms.modules' import_name = 'realms.modules'
FROMLIST = ( fromlist = (
'assets', 'assets',
'commands', 'commands',
'models', 'models',
@ -78,10 +74,10 @@ class Application(Flask):
start_time = time.time() start_time = time.time()
__import__(IMPORT_NAME, fromlist=FROMLIST) __import__(import_name, fromlist=fromlist)
for module_name in self.config['MODULES']: for module_name in self.config['MODULES']:
sources = __import__('%s.%s' % (IMPORT_NAME, module_name), fromlist=FROMLIST) sources = __import__('%s.%s' % (import_name, module_name), fromlist=fromlist)
# Blueprint # Blueprint
if hasattr(sources, 'views'): if hasattr(sources, 'views'):
@ -107,6 +103,18 @@ class Application(Flask):
return super(Application, self).make_response(tuple(rv)) return super(Application, self).make_response(tuple(rv))
class Assets(Environment):
default_filters = {'js': 'uglifyjs', 'css': 'cssmin'}
default_output = {'js': 'assets/%(version)s.js', 'css': 'assets/%(version)s.css'}
def register(self, name, *args, **kwargs):
ext = args[0].split('.')[-1]
filters = kwargs.get('filters', self.default_filters[ext])
output = kwargs.get('output', self.default_output[ext])
super(Assets, self).register(name, Bundle(*args, filters=filters, output=output))
class RegexConverter(BaseConverter): class RegexConverter(BaseConverter):
""" """
Enables Regex matching on endpoints Enables Regex matching on endpoints
@ -122,20 +130,6 @@ def redirect_url(referrer=None):
return request.args.get('next') or referrer or url_for('index') return request.args.get('next') or referrer or url_for('index')
app = Application(__name__)
app.config.from_object('realms.config')
app.session_interface = RedisSessionInterface()
app.url_map.converters['regex'] = RegexConverter
app.url_map.strict_slashes = False
app.debug = config.DEBUG
manager = Manager(app)
login_manager = LoginManager()
login_manager.init_app(app)
def error_handler(e): def error_handler(e):
try: try:
if isinstance(e, HTTPException): if isinstance(e, HTTPException):
@ -150,7 +144,7 @@ def error_handler(e):
if request.is_xhr or request.accept_mimetypes.best in ['application/json', 'text/javascript']: if request.is_xhr or request.accept_mimetypes.best in ['application/json', 'text/javascript']:
response = { response = {
'message': message, 'message': message,
'traceback': tb, 'traceback': tb
} }
else: else:
response = render_template('errors/error.html', response = render_template('errors/error.html',
@ -163,16 +157,49 @@ def error_handler(e):
return response, status_code return response, status_code
def create_app():
app = Application(__name__)
app.config.from_object('realms.config')
app.url_map.converters['regex'] = RegexConverter
app.url_map.strict_slashes = False
for status_code in httplib.responses: for status_code in httplib.responses:
if status_code >= 400: if status_code >= 400:
app.register_error_handler(status_code, error_handler) app.register_error_handler(status_code, error_handler)
from realms.lib.assets import register, assets @app.before_request
assets.init_app(app) def init_g():
assets.app = app g.assets = ['main']
assets.debug = config.DEBUG
register('main', @app.template_filter('datetime')
def _jinja2_filter_datetime(ts):
return time.strftime('%b %d, %Y %I:%M %p', time.localtime(ts))
@app.errorhandler(404)
def page_not_found(e):
return render_template('errors/404.html'), 404
if config.RELATIVE_PATH:
@app.route("/")
def root():
return redirect(url_for(config.ROOT_ENDPOINT))
return app
app = create_app()
# Init plugins here if possible
manager = Manager(app)
login_manager = LoginManager(app)
login_manager.login_view = 'auth.login'
db = SQLAlchemy(app)
cache = Cache(app)
assets = Environment(app)
assets.register('main',
'vendor/jquery/jquery.js', 'vendor/jquery/jquery.js',
'vendor/components-underscore/underscore.js', 'vendor/components-underscore/underscore.js',
'vendor/components-bootstrap/js/bootstrap.js', 'vendor/components-bootstrap/js/bootstrap.js',
@ -180,43 +207,17 @@ register('main',
'vendor/showdown/src/showdown.js', 'vendor/showdown/src/showdown.js',
'vendor/showdown/src/extensions/table.js', 'vendor/showdown/src/extensions/table.js',
'js/wmd.js', 'js/wmd.js',
'js/html-sanitizer-minified.js', # don't minify 'js/html-sanitizer-minified.js', # don't minify?
'vendor/highlightjs/highlight.pack.js', 'vendor/highlightjs/highlight.pack.js',
'vendor/parsleyjs/dist/parsley.js', 'vendor/parsleyjs/dist/parsley.js',
'js/main.js') 'js/main.js')
@app.before_request
def init_g():
g.assets = ['main']
@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')
def _jinja2_filter_datetime(ts):
return time.strftime('%b %d, %Y %I:%M %p', time.localtime(ts))
@app.errorhandler(404)
def page_not_found(e):
return render_template('errors/404.html'), 404
if config.RELATIVE_PATH:
@app.route("/")
def root():
return redirect(url_for(config.ROOT_ENDPOINT))
app.discover() app.discover()
db.create_all()

View file

@ -7,19 +7,35 @@ ENV = 'DEV'
DEBUG = True DEBUG = True
ASSETS_DEBUG = True ASSETS_DEBUG = True
SQLALCHEMY_ECHO = True
PORT = 80 PORT = 80
BASE_URL = 'http://realms.dev' BASE_URL = 'http://realms.dev'
REDIS_HOST = '127.0.0.1' DB_URI = 'sqlite:////home/deploy/wiki.db'
REDIS_PORT = 6379
REDIS_DB = '0'
SECRET = 'K3dRq1q9eN72GJDkgvyshFVwlqHHCyPI' CACHE_TYPE = 'simple'
# Redis Example
"""
CACHE_TYPE = 'redis'
CACHE_REDIS_HOST = '127.0.0.1'
CACHE_REDIS_PORT = 6379
CACHE_REDIS_DB = '0'
"""
RECAPTCHA_ENABLE = True
RECAPTCHA_USE_SSL = False
RECAPTCHA_PUBLIC_KEY = "6LfYbPkSAAAAAB4a2lG2Y_Yjik7MG9l4TDzyKUao"
RECAPTCHA_PRIVATE_KEY = "6LfYbPkSAAAAAG-KlkwjZ8JLWgwc9T0ytkN7lWRE"
RECAPTCHA_OPTIONS = {}
SECRET_KEY = 'K3dRq1q9eN72GJDkgvyshFVwlqHHCyPI'
WIKI_PATH = '/home/deploy/wiki' WIKI_PATH = '/home/deploy/wiki'
WIKI_HOME = 'home' WIKI_HOME = 'home'
ALLOW_ANON = True ALLOW_ANON = True
LOGIN_DISABLED = ALLOW_ANON
ROOT_ENDPOINT = 'wiki.page' ROOT_ENDPOINT = 'wiki.page'
@ -27,10 +43,11 @@ with open(os.path.join(os.path.dirname(__file__) + "/../../", 'config.json')) as
__settings = json.load(f) __settings = json.load(f)
globals().update(__settings) globals().update(__settings)
# String trailing slash
if BASE_URL.endswith('/'): if BASE_URL.endswith('/'):
BASE_URL = BASE_URL[-1] BASE_URL = BASE_URL[-1]
SQLALCHEMY_DATABASE_URI = DB_URI
_url = urlparse(BASE_URL) _url = urlparse(BASE_URL)
RELATIVE_PATH = _url.path RELATIVE_PATH = _url.path

View file

@ -1,11 +0,0 @@
from flask.ext.assets import Bundle, Environment
# This can be done better, make it better
assets = Environment()
filters = 'uglifyjs'
output = 'assets/%(version)s.js'
def register(name, *files):
assets.register(name, Bundle(*files, filters=filters, output=output))

287
realms/lib/model.py Normal file
View file

@ -0,0 +1,287 @@
import json
from realms import db
from sqlalchemy import not_
from datetime import datetime
class Model(db.Model):
"""Base SQLAlchemy Model for automatic serialization and
deserialization of columns and nested relationships.
Source: https://gist.github.com/alanhamlett/6604662
Usage::
>>> class User(Model):
>>> id = db.Column(db.Integer(), primary_key=True)
>>> email = db.Column(db.String(), index=True)
>>> name = db.Column(db.String())
>>> password = db.Column(db.String())
>>> posts = db.relationship('Post', backref='user', lazy='dynamic')
>>> ...
>>> default_fields = ['email', 'name']
>>> hidden_fields = ['password']
>>> readonly_fields = ['email', 'password']
>>>
>>> class Post(Model):
>>> id = db.Column(db.Integer(), primary_key=True)
>>> user_id = db.Column(db.String(), db.ForeignKey('user.id'), nullable=False)
>>> title = db.Column(db.String())
>>> ...
>>> default_fields = ['title']
>>> readonly_fields = ['user_id']
>>>
>>> model = User(email='john@localhost')
>>> db.session.add(model)
>>> db.session.commit()
>>>
>>> # update name and create a new post
>>> validated_input = {'name': 'John', 'posts': [{'title':'My First Post'}]}
>>> model.set_columns(**validated_input)
>>> db.session.commit()
>>>
>>> print(model.to_dict(show=['password', 'posts']))
>>> {u'email': u'john@localhost', u'posts': [{u'id': 1, u'title': u'My First Post'}], u'name': u'John', u'id': 1}
"""
__abstract__ = True
# Stores changes made to this model's attributes. Can be retrieved
# with model.changes
_changes = {}
def __init__(self, **kwargs):
kwargs['_force'] = True
self._set_columns(**kwargs)
def _set_columns(self, **kwargs):
force = kwargs.get('_force')
readonly = []
if hasattr(self, 'readonly_fields'):
readonly = self.readonly_fields
if hasattr(self, 'hidden_fields'):
readonly += self.hidden_fields
readonly += [
'id',
'created',
'updated',
'modified',
'created_at',
'updated_at',
'modified_at',
]
changes = {}
columns = self.__table__.columns.keys()
relationships = self.__mapper__.relationships.keys()
for key in columns:
allowed = True if force or key not in readonly else False
exists = True if key in kwargs else False
if allowed and exists:
val = getattr(self, key)
if val != kwargs[key]:
changes[key] = {'old': val, 'new': kwargs[key]}
setattr(self, key, kwargs[key])
for rel in relationships:
allowed = True if force or rel not in readonly else False
exists = True if rel in kwargs else False
if allowed and exists:
is_list = self.__mapper__.relationships[rel].uselist
if is_list:
valid_ids = []
query = getattr(self, rel)
cls = self.__mapper__.relationships[rel].argument()
for item in kwargs[rel]:
if 'id' in item and query.filter_by(id=item['id']).limit(1).count() == 1:
obj = cls.query.filter_by(id=item['id']).first()
col_changes = obj.set_columns(**item)
if col_changes:
col_changes['id'] = str(item['id'])
if rel in changes:
changes[rel].append(col_changes)
else:
changes.update({rel: [col_changes]})
valid_ids.append(str(item['id']))
else:
col = cls()
col_changes = col.set_columns(**item)
query.append(col)
db.session.flush()
if col_changes:
col_changes['id'] = str(col.id)
if rel in changes:
changes[rel].append(col_changes)
else:
changes.update({rel: [col_changes]})
valid_ids.append(str(col.id))
# delete related rows that were not in kwargs[rel]
for item in query.filter(not_(cls.id.in_(valid_ids))).all():
col_changes = {
'id': str(item.id),
'deleted': True,
}
if rel in changes:
changes[rel].append(col_changes)
else:
changes.update({rel: [col_changes]})
db.session.delete(item)
else:
val = getattr(self, rel)
if self.__mapper__.relationships[rel].query_class is not None:
if val is not None:
col_changes = val.set_columns(**kwargs[rel])
if col_changes:
changes.update({rel: col_changes})
else:
if val != kwargs[rel]:
setattr(self, rel, kwargs[rel])
changes[rel] = {'old': val, 'new': kwargs[rel]}
return changes
def set_columns(self, **kwargs):
self._changes = self._set_columns(**kwargs)
if 'modified' in self.__table__.columns:
self.modified = datetime.utcnow()
if 'updated' in self.__table__.columns:
self.updated = datetime.utcnow()
if 'modified_at' in self.__table__.columns:
self.modified_at = datetime.utcnow()
if 'updated_at' in self.__table__.columns:
self.updated_at = datetime.utcnow()
return self._changes
def __repr__(self):
if 'id' in self.__table__.columns.keys():
return '%s(%s)' % (self.__class__.__name__, self.id)
data = {}
for key in self.__table__.columns.keys():
val = getattr(self, key)
if type(val) is datetime:
val = val.strftime('%Y-%m-%dT%H:%M:%SZ')
data[key] = val
return json.dumps(data, use_decimal=True)
@property
def changes(self):
return self._changes
def reset_changes(self):
self._changes = {}
def to_dict(self, show=None, hide=None, path=None, show_all=None):
""" Return a dictionary representation of this model.
"""
if not show:
show = []
if not hide:
hide = []
hidden = []
if hasattr(self, 'hidden_fields'):
hidden = self.hidden_fields
default = []
if hasattr(self, 'default_fields'):
default = self.default_fields
ret_data = {}
if not path:
path = self.__tablename__.lower()
def prepend_path(item):
item = item.lower()
if item.split('.', 1)[0] == path:
return item
if len(item) == 0:
return item
if item[0] != '.':
item = '.%s' % item
item = '%s%s' % (path, item)
return item
show[:] = [prepend_path(x) for x in show]
hide[:] = [prepend_path(x) for x in hide]
columns = self.__table__.columns.keys()
relationships = self.__mapper__.relationships.keys()
properties = dir(self)
for key in columns:
check = '%s.%s' % (path, key)
if check in hide or key in hidden:
continue
if show_all or key is 'id' or check in show or key in default:
ret_data[key] = getattr(self, key)
for key in relationships:
check = '%s.%s' % (path, key)
if check in hide or key in hidden:
continue
if show_all or check in show or key in default:
hide.append(check)
is_list = self.__mapper__.relationships[key].uselist
if is_list:
ret_data[key] = []
for item in getattr(self, key):
ret_data[key].append(item.to_dict(
show=show,
hide=hide,
path=('%s.%s' % (path, key.lower())),
show_all=show_all,
))
else:
if self.__mapper__.relationships[key].query_class is not None:
ret_data[key] = getattr(self, key).to_dict(
show=show,
hide=hide,
path=('%s.%s' % (path, key.lower())),
show_all=show_all,
)
else:
ret_data[key] = getattr(self, key)
for key in list(set(properties) - set(columns) - set(relationships)):
if key.startswith('_'):
continue
check = '%s.%s' % (path, key)
if check in hide or key in hidden:
continue
if show_all or check in show or key in default:
val = getattr(self, key)
try:
ret_data[key] = json.loads(json.dumps(val))
except:
pass
return ret_data
@classmethod
def insert_or_update(cls, cond, data):
obj = cls.query.filter_by(**cond).first()
if obj:
obj.set_columns(**data)
else:
data.update(cond)
obj = cls(**data)
db.session.add(obj)
db.session.commit()
def save(self):
if self not in db.session:
db.session.merge(self)
db.session.commit()
def delete(self):
if self not in db.session:
db.session.merge(self)
db.session.delete(self)
db.session.commit()
@classmethod
def get_by_id(cls, id_):
return cls.query.filter_by(id=id_).first()

View file

@ -1,46 +0,0 @@
import time
from functools import update_wrapper
from flask import request, g
from services import db
class RateLimit(object):
expiration_window = 10
def __init__(self, key_prefix, limit, per, send_x_headers):
self.reset = (int(time.time()) // per) * per + per
self.key = key_prefix + str(self.reset)
self.limit = limit
self.per = per
self.send_x_headers = send_x_headers
p = db.pipeline()
p.incr(self.key)
p.expireat(self.key, self.reset + self.expiration_window)
self.current = min(p.execute()[0], limit)
remaining = property(lambda x: x.limit - x.current)
over_limit = property(lambda x: x.current >= x.limit)
def get_view_rate_limit():
return getattr(g, '_view_rate_limit', None)
def on_over_limit(limit):
return 'Slow it down', 400
def ratelimiter(limit, per=300, send_x_headers=True,
over_limit=on_over_limit,
scope_func=lambda: request.remote_addr,
key_func=lambda: request.endpoint):
def decorator(f):
def rate_limited(*args, **kwargs):
key = 'rate-limit/%s/%s/' % (key_func(), scope_func())
rlimit = RateLimit(key, limit, per, send_x_headers)
g._view_rate_limit = rlimit
if over_limit is not None and rlimit.over_limit:
return over_limit(rlimit)
return f(*args, **kwargs)
return update_wrapper(rate_limited, f)
return decorator

View file

@ -1,4 +0,0 @@
import redis
from realms import config
db = redis.StrictRedis(host=config.REDIS_HOST, port=config.REDIS_PORT, db=config.REDIS_DB)

View file

@ -1,64 +0,0 @@
import pickle
from datetime import timedelta
from uuid import uuid4
from redis import Redis
from werkzeug.datastructures import CallbackDict
from flask.sessions import SessionInterface, SessionMixin
class RedisSession(CallbackDict, SessionMixin):
def __init__(self, initial=None, sid=None, new=False):
def on_update(self):
self.modified = True
CallbackDict.__init__(self, initial, on_update)
self.sid = sid
self.new = new
self.modified = False
class RedisSessionInterface(SessionInterface):
serializer = pickle
session_class = RedisSession
def __init__(self, redis=None, prefix='session:'):
if redis is None:
redis = Redis()
self.redis = redis
self.prefix = prefix
def generate_sid(self):
return str(uuid4())
def get_redis_expiration_time(self, app, session):
if session.permanent:
return app.permanent_session_lifetime
return timedelta(days=1)
def open_session(self, app, request):
sid = request.cookies.get(app.session_cookie_name)
if not sid:
sid = self.generate_sid()
return self.session_class(sid=sid)
val = self.redis.get(self.prefix + sid)
if val is not None:
data = self.serializer.loads(val)
return self.session_class(data, sid=sid)
return self.session_class(sid=sid, new=True)
def save_session(self, app, session, response):
domain = self.get_cookie_domain(app)
if not session:
self.redis.delete(self.prefix + session.sid)
if session.modified:
response.delete_cookie(app.session_cookie_name,
domain=domain)
return
redis_exp = self.get_redis_expiration_time(app, session)
cookie_exp = self.get_expiration_time(app, session)
val = self.serializer.dumps(dict(session))
self.redis.setex(self.prefix + session.sid, val,
int(redis_exp.total_seconds()))
response.set_cookie(app.session_cookie_name, session.sid,
expires=cookie_exp, httponly=True,
domain=domain)

View file

@ -3,8 +3,6 @@ import os
import hashlib import hashlib
import json import json
from realms.lib.services import db
class AttrDict(dict): class AttrDict(dict):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -34,36 +32,6 @@ def to_dict(data):
return row2dict(data) return row2dict(data)
def cache_it(fn):
def wrap(*args, **kw):
key = "%s:%s" % (args[0].table, args[1])
data = db.get(key)
# Assume strings are JSON encoded
try:
data = json.loads(data)
except TypeError:
pass
except ValueError:
pass
if data is not None:
return data
else:
data = fn(*args)
print data
ret = data
if data is None:
data = ''
if not isinstance(data, basestring):
try:
data = json.dumps(data, separators=(',', ':'))
except TypeError:
pass
db.set(key, data)
return ret
return wrap
def mkdir_safe(path): def mkdir_safe(path):
if path and not(os.path.exists(path)): if path and not(os.path.exists(path)):
os.makedirs(path) os.makedirs(path)

View file

@ -1,5 +1,6 @@
from wtforms import Form, StringField, PasswordField, validators from flask_wtf import Form, RecaptchaField
from wtforms import StringField, PasswordField, validators
from realms import config
class RegistrationForm(Form): class RegistrationForm(Form):
username = StringField('Username', [validators.Length(min=4, max=25)]) username = StringField('Username', [validators.Length(min=4, max=25)])
@ -10,9 +11,12 @@ class RegistrationForm(Form):
]) ])
confirm = PasswordField('Repeat Password') confirm = PasswordField('Repeat Password')
if config.RECAPTCHA_ENABLE:
setattr(RegistrationForm, 'recaptcha', RecaptchaField("You Human?"))
class LoginForm(Form): class LoginForm(Form):
email = StringField('Email', [validators.DataRequired]) email = StringField('Email', [validators.DataRequired()])
password = PasswordField('Password', [validators.DataRequired]) password = PasswordField('Password', [validators.DataRequired()])

View file

@ -1,23 +1,15 @@
from flask.ext.login import UserMixin, logout_user, login_user from flask.ext.login import UserMixin, logout_user, login_user, AnonymousUserMixin
from realms import config, login_manager from realms import config, login_manager, db
from realms.lib.services import db from realms.lib.model import Model
from realms.lib.util import gravatar_url
from itsdangerous import URLSafeSerializer, BadSignature from itsdangerous import URLSafeSerializer, BadSignature
from hashlib import sha256 from hashlib import sha256
import json
import bcrypt import bcrypt
FIELD_MAP = dict(
u='username',
e='email',
p='password',
nv='not_verified',
a='admin',
b='banned')
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
return User.get(user_id) return User.get_by_id(user_id)
@login_manager.token_loader @login_manager.token_loader
@ -29,7 +21,7 @@ def load_token(token):
return False return False
# User key *could* be stored in payload to avoid user lookup in db # User key *could* be stored in payload to avoid user lookup in db
user = User.get(payload.get('id')) user = User.get_by_id(payload.get('id'))
if not user: if not user:
return False return False
@ -43,68 +35,78 @@ def load_token(token):
return False return False
class User(UserMixin): class AnonUser(AnonymousUserMixin):
username = 'Anon'
email = ''
username = None
email = None
password = None
def __init__(self, email, data=None): class User(Model, UserMixin):
self.id = email __tablename__ = 'users'
for k, v in data.items(): id = db.Column(db.Integer, primary_key=True)
setattr(self, FIELD_MAP.get(k, k), v) username = db.Column(db.String, unique=True)
email = db.Column(db.String, unique=True)
password = db.Column(db.String)
hidden_fields = ['password']
readonly_fields = ['email', 'password']
def get_auth_token(self): def get_auth_token(self):
key = sha256(self.password).hexdigest() key = sha256(self.password).hexdigest()
return User.signer(key).dumps(dict(id=self.username)) return User.signer(key).dumps(dict(id=self.id))
@property
def avatar(self):
return gravatar_url(self.email)
@staticmethod @staticmethod
def create(username, email, password): def create(username, email, password):
User.set(email, dict(u=username, e=email, p=User.hash(password), nv=1)) u = User()
u.username = username
u.email = email
u.password = User.hash_password(password)
u.save()
@staticmethod
def get_by_username(username):
return User.query.filter_by(username=username).first()
@staticmethod
def get_by_email(email):
return User.query.filter_by(email=email).first()
@staticmethod @staticmethod
def signer(salt): def signer(salt):
""" """
Signed with app secret salted with sha256 of password hash of user (client secret) Signed with app secret salted with sha256 of password hash of user (client secret)
""" """
return URLSafeSerializer(config.SECRET + salt) return URLSafeSerializer(config.SECRET_KEY + salt)
@staticmethod
def set(email, data):
db.set('u:%s' % email, json.dumps(data, separators=(',', ':')))
@staticmethod
def get(email):
data = db.get('u:%s', email)
try:
data = json.loads(data)
except ValueError:
return None
if data:
return User(email, data)
else:
return None
@staticmethod @staticmethod
def auth(email, password): def auth(email, password):
user = User.get(email) user = User.query.filter_by(email=email).first()
if not user: if not user:
# User doesn't exist
return False return False
if bcrypt.checkpw(password, user.password): if User.check_password(password, user.password):
# Password is good, log in user
login_user(user, remember=True) login_user(user, remember=True)
return user return user
else: else:
# Password check failed
return False return False
@staticmethod @staticmethod
def hash(password): def hash_password(password):
return bcrypt.hashpw(password, bcrypt.gensalt(log_rounds=12)) return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(12))
@staticmethod
def check_password(password, hashed):
return bcrypt.hashpw(password.encode('utf-8'), hashed.encode('utf-8')) == hashed
@classmethod @classmethod
def logout(cls): def logout(cls):
logout_user() logout_user()
login_manager.anonymous_user = AnonUser

View file

@ -13,24 +13,55 @@ def logout_page():
return redirect(url_for(config.ROOT_ENDPOINT)) return redirect(url_for(config.ROOT_ENDPOINT))
@blueprint.route("/login") @blueprint.route("/login", methods=['GET', 'POST'])
def login(): def login():
if request.method == "POST": form = LoginForm()
form = RegistrationForm()
# TODO if request.method == "POST":
if not form.validate(): if not form.validate():
flash('Form invalid') flash('Form invalid', 'warning')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
if User.auth(request.form['email'], request.form['password']): if User.auth(request.form['email'], request.form['password']):
return redirect(request.args.get("next") or url_for(config.ROOT_ENDPOINT)) return redirect(request.args.get("next") or url_for(config.ROOT_ENDPOINT))
return render_template("auth/login.html")
@blueprint.route("/register")
def register():
if request.method == "POST":
return redirect(request.args.get("next") or url_for(config.ROOT_ENDPOINT))
else: else:
return render_template("auth/register.html") flash('Email or Password Incorrect', 'warning')
return redirect(url_for('auth.login'))
return render_template("auth/login.html", form=form)
@blueprint.route("/register", methods=['GET', 'POST'])
def register():
form = RegistrationForm()
if request.method == "POST":
if not form.validate():
flash('Form invalid', 'warning')
return redirect(url_for('auth.register'))
if User.get_by_username(request.form['username']):
flash('Username is taken', 'warning')
return redirect(url_for('auth.register'))
if User.get_by_email(request.form['email']):
flash('Email is taken', 'warning')
return redirect(url_for('auth.register'))
User.create(request.form['username'], request.form['email'], request.form['password'])
User.auth(request.form['email'], request.form['password'])
return redirect(request.args.get("next") or url_for(config.ROOT_ENDPOINT))
return render_template("auth/register.html", form=form)
@blueprint.route("/settings", methods=['GET', 'POST'])
def settings():
return render_template("auth/settings.html")
@blueprint.route("/logout")
def logout():
User.logout()
return redirect("/")

View file

@ -1,7 +1,6 @@
from realms.lib.assets import register from realms import assets
register( assets.register('editor',
'editor',
'js/ace/ace.js', 'js/ace/ace.js',
'js/ace/mode-markdown.js', 'js/ace/mode-markdown.js',
'vendor/keymaster/keymaster.js', 'vendor/keymaster/keymaster.js',

View file

@ -7,7 +7,8 @@ import gittle.utils
from gittle import Gittle from gittle import Gittle
from dulwich.repo import NotGitRepository from dulwich.repo import NotGitRepository
from werkzeug.utils import escape, unescape from werkzeug.utils import escape, unescape
from util import to_canonical from realms.lib.util import to_canonical
from realms import cache
class MyGittle(Gittle): class MyGittle(Gittle):
@ -94,7 +95,7 @@ class Wiki():
content = lxml.html.tostring(tree, encoding='utf-8', method='html') content = lxml.html.tostring(tree, encoding='utf-8', method='html')
# post processing to fix errors # remove added div tags
content = content[5:-6] content = content[5:-6]
# FIXME this is for block quotes, doesn't work for double ">" # FIXME this is for block quotes, doesn't work for double ">"
@ -103,7 +104,8 @@ class Wiki():
content = re.sub(r"```(.*?)```", unescape_repl, content, flags=re.DOTALL) content = re.sub(r"```(.*?)```", unescape_repl, content, flags=re.DOTALL)
filename = self.cname_to_filename(to_canonical(name)) cname = to_canonical(name)
filename = self.cname_to_filename(cname)
with open(self.path + "/" + filename, 'w') as f: with open(self.path + "/" + filename, 'w') as f:
f.write(content) f.write(content)
@ -119,11 +121,15 @@ class Wiki():
if not email: if not email:
email = self.default_committer_email email = self.default_committer_email
return self.repo.commit(name=username, ret = self.repo.commit(name=username,
email=email, email=email,
message=message, message=message,
files=[filename]) files=[filename])
cache.delete_memoized(Wiki.get_page, cname)
return ret
def rename_page(self, old_name, new_name): def rename_page(self, old_name, new_name):
old_name, new_name = map(self.cname_to_filename, [old_name, new_name]) old_name, new_name = map(self.cname_to_filename, [old_name, new_name])
self.repo.mv([(old_name, new_name)]) self.repo.mv([(old_name, new_name)])
@ -131,7 +137,10 @@ class Wiki():
email=self.default_committer_email, email=self.default_committer_email,
message="Moving %s to %s" % (old_name, new_name), message="Moving %s to %s" % (old_name, new_name),
files=[old_name]) files=[old_name])
cache.delete_memoized(Wiki.get_page, old_name)
cache.delete_memoized(Wiki.get_page, new_name)
@cache.memoize()
def get_page(self, name, sha='HEAD'): def get_page(self, name, sha='HEAD'):
# commit = gittle.utils.git.commit_info(self.repo[sha]) # commit = gittle.utils.git.commit_info(self.repo[sha])
name = self.cname_to_filename(name).encode('latin-1') name = self.cname_to_filename(name).encode('latin-1')
@ -151,5 +160,6 @@ class Wiki():
def get_history(self, name): def get_history(self, name):
return self.repo.file_history(self.cname_to_filename(name)) return self.repo.file_history(self.cname_to_filename(name))
def cname_to_filename(self, cname): @staticmethod
def cname_to_filename(cname):
return cname.lower() + ".md" return cname.lower() + ".md"

View file

@ -1,5 +0,0 @@
import realms
c = realms.app.test_client()
print c.get('/wiki/_create')
print c.get('/wiki/_create/blah')

View file

@ -1,15 +1,19 @@
from flask import g, render_template, request, redirect, Blueprint, flash, url_for from flask import g, render_template, request, redirect, Blueprint, flash, url_for, current_app
from flask.ext.login import login_required
from realms.lib.util import to_canonical, remove_ext from realms.lib.util import to_canonical, remove_ext
from realms import config from realms.modules.wiki.models import Wiki
from realms import config, current_user
blueprint = Blueprint('wiki', __name__, url_prefix=config.RELATIVE_PATH) blueprint = Blueprint('wiki', __name__, url_prefix=config.RELATIVE_PATH)
wiki = Wiki(config.WIKI_PATH)
@blueprint.route("/_commit/<sha>/<name>") @blueprint.route("/_commit/<sha>/<name>")
def commit(name, sha): def commit(name, sha):
cname = to_canonical(name) cname = to_canonical(name)
data = g.current_wiki.get_page(cname, sha=sha) data = wiki.get_page(cname, sha=sha)
if data: if data:
return render_template('wiki/page.html', name=name, page=data, commit=sha) return render_template('wiki/page.html', name=name, page=data, commit=sha)
else: else:
@ -18,41 +22,42 @@ def commit(name, sha):
@blueprint.route("/_compare/<name>/<regex('[^.]+'):fsha><regex('\.{2,3}'):dots><regex('.+'):lsha>") @blueprint.route("/_compare/<name>/<regex('[^.]+'):fsha><regex('\.{2,3}'):dots><regex('.+'):lsha>")
def compare(name, fsha, dots, lsha): def compare(name, fsha, dots, lsha):
diff = g.current_wiki.compare(name, fsha, lsha) diff = wiki.compare(name, fsha, lsha)
return render_template('wiki/compare.html', name=name, diff=diff, old=fsha, new=lsha) return render_template('wiki/compare.html', name=name, diff=diff, old=fsha, new=lsha)
@blueprint.route("/_revert", methods=['POST']) @blueprint.route("/_revert", methods=['POST'])
@login_required
def revert(): def revert():
if request.method == 'POST':
name = request.form.get('name') name = request.form.get('name')
commit = request.form.get('commit') commit = request.form.get('commit')
cname = to_canonical(name) cname = to_canonical(name)
g.current_wiki.revert_page(name, commit, message="Reverting %s" % cname, wiki.revert_page(name, commit, message="Reverting %s" % cname,
username=g.current_user.get('username')) username=g.current_user.username)
flash('Page reverted', 'success') flash('Page reverted', 'success')
return redirect(url_for('wiki.page', name=cname)) return redirect(url_for('wiki.page', name=cname))
@blueprint.route("/_history/<name>") @blueprint.route("/_history/<name>")
def history(name): def history(name):
history = g.current_wiki.get_history(name) history = wiki.get_history(name)
return render_template('wiki/history.html', name=name, history=history, wiki_home=url_for('wiki.page')) return render_template('wiki/history.html', name=name, history=history, wiki_home=url_for('wiki.page'))
@blueprint.route("/_edit/<name>", methods=['GET', 'POST']) @blueprint.route("/_edit/<name>", methods=['GET', 'POST'])
@login_required
def edit(name): def edit(name):
data = g.current_wiki.get_page(name) data = wiki.get_page(name)
cname = to_canonical(name) cname = to_canonical(name)
if request.method == 'POST': if request.method == 'POST':
edit_cname = to_canonical(request.form['name']) edit_cname = to_canonical(request.form['name'])
if edit_cname.lower() != cname.lower(): if edit_cname.lower() != cname.lower():
g.current_wiki.rename_page(cname, edit_cname) wiki.rename_page(cname, edit_cname)
g.current_wiki.write_page(edit_cname, wiki.write_page(edit_cname,
request.form['content'], request.form['content'],
message=request.form['message'], message=request.form['message'],
username=g.current_user.get('username')) username=g.current_user.username)
else: else:
if data: if data:
name = remove_ext(data['name']) name = remove_ext(data['name'])
@ -64,22 +69,24 @@ def edit(name):
@blueprint.route("/_delete/<name>", methods=['POST']) @blueprint.route("/_delete/<name>", methods=['POST'])
@login_required
def delete(name): def delete(name):
pass pass
@blueprint.route("/_create/", defaults={'name': None}, methods=['GET', 'POST']) @blueprint.route("/_create/", defaults={'name': None}, methods=['GET', 'POST'])
@blueprint.route("/_create/<name>", methods=['GET', 'POST']) @blueprint.route("/_create/<name>", methods=['GET', 'POST'])
@login_required
def create(name): def create(name):
if request.method == 'POST': if request.method == 'POST':
g.current_wiki.write_page(request.form['name'], wiki.write_page(request.form['name'],
request.form['content'], request.form['content'],
message=request.form['message'], message=request.form['message'],
create=True, create=True,
username=g.current_user.get('username')) username=g.current_user.username)
else: else:
cname = to_canonical(name) if name else "" cname = to_canonical(name) if name else ""
if cname and g.current_wiki.get_page(cname): if cname and wiki.get_page(cname):
# Page exists, edit instead # Page exists, edit instead
return redirect(url_for('wiki.edit', name=cname)) return redirect(url_for('wiki.edit', name=cname))
@ -94,7 +101,7 @@ def page(name):
if cname != name: if cname != name:
return redirect(url_for('wiki.page', name=cname)) return redirect(url_for('wiki.page', name=cname))
data = g.current_wiki.get_page(cname) data = wiki.get_page(cname)
if data: if data:
return render_template('wiki/page.html', name=cname, page=data) return render_template('wiki/page.html', name=cname, page=data)

View file

@ -145,14 +145,8 @@ body {
border-radius: 2px; border-radius: 2px;
} }
.navbar-nav>li.user-avatar img {
.navbar-nav .user-avatar a { margin-right: 3px;
}
.navbar-nav>li.user-avatar a {
padding-top: 9px;
padding-bottom: 9px;
} }
.floating-header { .floating-header {
@ -170,3 +164,40 @@ body {
/* background-image: linear-gradient(to bottom,#fff 0%,#fff 25%,rgba(255,255,255,0.9) 100%); */ /* background-image: linear-gradient(to bottom,#fff 0%,#fff 25%,rgba(255,255,255,0.9) 100%); */
font-size: 10px; font-size: 10px;
} }
input.parsley-success,
select.parsley-success,
textarea.parsley-success {
color: #468847;
background-color: #DFF0D8;
border: 1px solid #D6E9C6;
}
input.parsley-error,
select.parsley-error,
textarea.parsley-error {
color: #B94A48;
background-color: #F2DEDE;
border: 1px solid #EED3D7;
}
.parsley-errors-list {
margin: 2px 0 3px 0;
padding: 0;
list-style-type: none;
font-size: 0.9em;
line-height: 0.9em;
opacity: 0;
-moz-opacity: 0;
-webkit-opacity: 0;
transition: all .3s ease-in;
-o-transition: all .3s ease-in;
-ms-transition: all .3s ease-in;
-moz-transition: all .3s ease-in;
-webkit-transition: all .3s ease-in;
}
.parsley-errors-list.filled {
opacity: 1;
}

View file

@ -1,18 +1,8 @@
{% extends 'layout.html' %} {% extends 'layout.html' %}
{% from 'macros.html' import render_form, render_field %}
{% block body %} {% block body %}
{% call render_form(form, action_url=url_for('auth.login'), action_text='Login', btn_class='btn btn-primary') %}
<form role="form" method="post" action="{{ url_for('auth.login') }}" data-parsley-validate> {{ render_field(form.email, placeholder='Email', type='email', required=1) }}
<div class="form-group"> {{ render_field(form.password, placeholder='Password', type='password', required=1) }}
<label for="email">Email</label> {% endcall %}
<input id="email" type="email" class="form-control" name="email" placeholder="Email" required />
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" name="password" id="password" class="form-control" placeholder="Password" min="6" required />
</div>
<input type="submit" class="btn btn-primary" value="Login" />
</form>
{% endblock %} {% endblock %}

View file

@ -1,29 +1,13 @@
{% extends 'layout.html' %} {% extends 'layout.html' %}
{% from 'macros.html' import render_form, render_field %}
{% block body %} {% block body %}
{% call render_form(form, action_url=url_for('auth.register'), action_text='Register', btn_class='btn btn-primary') %}
<form role="form" method="post" action="{{ url_for('auth.register') }}" data-parsley-validate> {{ render_field(form.username, placeholder='Username', type='username', **{"required": 1, "data-parsley-type": "alphanum"}) }}
<div class="form-group"> {{ render_field(form.email, placeholder='Email', type='email', required=1) }}
<label for="username">Username</label> {{ render_field(form.password, placeholder='Password', type='password', **{"required": 1, "data-parsley-minlength": "6"}) }}
<input id="username" type="text" class="form-control" name="username" placeholder="Username" required data-parsley-type="alphanum" /> {{ render_field(form.confirm, placeholder='Confirm Password', type='password', **{"required": 1, "data-parsley-minlength": "6"}) }}
</div> {% if config.RECAPTCHA_ENABLE %}
{{ render_field(form.recaptcha) }}
<div class="form-group"> {% endif %}
<label for="email">Email</label> {% endcall %}
<input id="email" type="email" class="form-control" name="email" placeholder="Email" required />
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" name="password" id="password" class="form-control" placeholder="Password" required min="6"/>
</div>
<div class="form-group">
<label for="password_again">Confirm Password</label>
<input type="password" name="password_again" id="password_again" class="form-control" placeholder="Password" required min="6"/>
</div>
<input type="submit" class="btn btn-primary" value="Register" />
</form>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,5 @@
{% extends 'layout.html' %}
{% from 'macros.html' import render_form, render_field %}
{% block body %}
{% endblock %}

View file

@ -43,16 +43,16 @@
{% endif %} {% endif %}
</ul> </ul>
<ul class="nav navbar-nav navbar-right"> <ul class="nav navbar-nav navbar-right">
{% if session.get('user') %} {% if g.current_user.is_authenticated() %}
<li class="dropdown user-avatar"> <li class="dropdown user-avatar">
<a href="#" class="dropdown-toggle" data-toggle="dropdown"> <a href="#" class="dropdown-toggle" data-toggle="dropdown">
<span> <span>
<img src="{{ session['user'].get('avatar') }}" class="menu-avatar"> <img src="{{ g.current_user.avatar }}" class="menu-avatar">
<span>{{ session['user'].get('username') }} <i class="icon-caret-down"></i></span> <span>{{ g.current_user.username }} <i class="icon-caret-down"></i></span>
</span> </span>
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a href="{{ url_for('account') }}">Account</a></li> <li><a href="{{ url_for('auth.settings') }}">Settings</a></li>
<li><a href="{{ url_for('auth.logout') }}">Logout</a></li> <li><a href="{{ url_for('auth.logout') }}">Logout</a></li>
</ul> </ul>
</li> </li>
@ -68,8 +68,6 @@
<!-- Page Menu --> <!-- Page Menu -->
<div class="container"> <div class="container">
<div id="main-body"> <div id="main-body">
{% with messages = get_flashed_messages(with_categories=True) %} {% with messages = get_flashed_messages(with_categories=True) %}

View file

@ -0,0 +1,99 @@
{# Source: https://gist.github.com/bearz/7394681 #}
{# Renders field for bootstrap 3 standards.
Params:
field - WTForm field
kwargs - pass any arguments you want in order to put them into the html attributes.
There are few exceptions: for - for_, class - class_, class__ - class_
Example usage:
{{ macros.render_field(form.email, placeholder='Input email', type='email') }}
#}
{% macro render_field(field, label_visible=true) -%}
<div class="form-group {% if field.errors %}has-error{% endif %} {{ kwargs.pop('class_', '') }}">
{% if (field.type != 'HiddenField' or field.type !='CSRFTokenField') and label_visible %}
<label for="{{ field.id }}" class="control-label">{{ field.label }}</label>
{% endif %}
{{ field(class_='form-control', **kwargs) }}
{% if field.errors %}
{% for e in field.errors %}
<p class="help-block">{{ e }}</p>
{% endfor %}
{% endif %}
</div>
{%- endmacro %}
{# Renders checkbox fields since they are represented differently in bootstrap
Params:
field - WTForm field (there are no check, but you should put here only BooleanField.
kwargs - pass any arguments you want in order to put them into the html attributes.
There are few exceptions: for - for_, class - class_, class__ - class_
Example usage:
{{ macros.render_checkbox_field(form.remember_me) }}
#}
{% macro render_checkbox_field(field) -%}
<div class="checkbox">
<label>
{{ field(type='checkbox', **kwargs) }} {{ field.label }}
</label>
</div>
{%- endmacro %}
{# Renders radio field
Params:
field - WTForm field (there are no check, but you should put here only BooleanField.
kwargs - pass any arguments you want in order to put them into the html attributes.
There are few exceptions: for - for_, class - class_, class__ - class_
Example usage:
{{ macros.render_radio_field(form.answers) }}
#}
{% macro render_radio_field(field) -%}
{% for value, label, _ in field.iter_choices() %}
<div class="radio">
<label>
<input type="radio" name="{{ field.id }}" id="{{ field.id }}" value="{{ value }}">{{ label }}
</label>
</div>
{% endfor %}
{%- endmacro %}
{# Renders WTForm in bootstrap way. There are two ways to call function:
- as macros: it will render all field forms using cycle to iterate over them
- as call: it will insert form fields as you specify:
e.g. {% call macros.render_form(form, action_url=url_for('login_view'), action_text='Login',
class_='login-form') %}
{{ macros.render_field(form.email, placeholder='Input email', type='email') }}
{{ macros.render_field(form.password, placeholder='Input password', type='password') }}
{{ macros.render_checkbox_field(form.remember_me, type='checkbox') }}
{% endcall %}
Params:
form - WTForm class
action_url - url where to submit this form
action_text - text of submit button
class_ - sets a class for form
#}
{% macro render_form(form, action_url='', action_text='Submit', class_='', btn_class='btn btn-default') -%}
<form method="POST" action="{{ action_url }}" role="form" class="{{ class_ }}" data-parsley-validate>
{{ form.hidden_tag() if form.hidden_tag }}
{% if caller %}
{{ caller() }}
{% else %}
{% for f in form %}
{% if f.type == 'BooleanField' %}
{{ render_checkbox_field(f) }}
{% elif f.type == 'RadioField' %}
{{ render_radio_field(f) }}
{% else %}
{{ render_field(f) }}
{% endif %}
{% endfor %}
{% endif %}
<button type="submit" class="{{ btn_class }}">{{ action_text }} </button>
</form>
{%- endmacro %}

View file

@ -1,8 +1,10 @@
bcrypt bcrypt
Flask Flask
Flask-Login
Flask-Assets Flask-Assets
Flask-Cache
Flask-Login
Flask-Script Flask-Script
Flask-SQLAlchemy
Flask-WTF Flask-WTF
beautifulsoup4 beautifulsoup4
closure==20121212 closure==20121212