From 86f0549e44733920048eb8ad9167fa91eeafcf7d Mon Sep 17 00:00:00 2001 From: Matthew Scragg Date: Sat, 30 Aug 2014 10:06:12 -0500 Subject: [PATCH] WIP --- Vagrantfile | 1 + manage.py | 8 +- realms/__init__.py | 143 ++++----- realms/config/__init__.py | 29 +- realms/lib/assets.py | 11 - realms/lib/model.py | 287 ++++++++++++++++++ realms/lib/ratelimit.py | 46 --- realms/lib/services.py | 4 - realms/lib/session.py | 64 ---- realms/lib/util.py | 32 -- realms/modules/auth/forms.py | 12 +- realms/modules/auth/models.py | 96 +++--- realms/modules/auth/views.py | 49 ++- realms/modules/wiki/assets.py | 13 +- .../{lib/wiki.py => modules/wiki/models.py} | 26 +- realms/modules/wiki/tests.py | 5 - realms/modules/wiki/views.py | 59 ++-- realms/static/css/style.css | 47 ++- realms/templates/auth/login.html | 22 +- realms/templates/auth/register.html | 36 +-- realms/templates/auth/settings.html | 5 + realms/templates/layout.html | 10 +- realms/templates/macros.html | 99 ++++++ requirements.txt | 4 +- 24 files changed, 710 insertions(+), 398 deletions(-) delete mode 100644 realms/lib/assets.py create mode 100644 realms/lib/model.py delete mode 100644 realms/lib/ratelimit.py delete mode 100644 realms/lib/services.py delete mode 100644 realms/lib/session.py rename realms/{lib/wiki.py => modules/wiki/models.py} (87%) delete mode 100644 realms/modules/wiki/tests.py create mode 100644 realms/templates/auth/settings.html create mode 100644 realms/templates/macros.html diff --git a/Vagrantfile b/Vagrantfile index 1986108..e6f98eb 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -11,6 +11,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| config.vm.synced_folder "srv/", "/srv/" config.vm.synced_folder ".", "/home/deploy/realms" + config.vm.synced_folder "~/.virtualenvs", "/home/deploy/virtualenvs" config.vm.provision :salt do |salt| salt.minion_config = "srv/minion" salt.run_highstate = true diff --git a/manage.py b/manage.py index a786a69..12203fc 100644 --- a/manage.py +++ b/manage.py @@ -1,9 +1,15 @@ from gevent import wsgi 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 -def server(): +def run(): + """ + Run production ready server + """ print "Server started. Env: %s Port: %s" % (config.ENV, config.PORT) wsgi.WSGIServer(('', int(config.PORT)), app).serve_forever() diff --git a/realms/__init__.py b/realms/__init__.py index 1532c0d..03f1f55 100644 --- a/realms/__init__.py +++ b/realms/__init__.py @@ -20,18 +20,18 @@ import sys import json import httplib 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.ext.cache import Cache 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.utils import cached_property from werkzeug.exceptions import HTTPException 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 @@ -39,11 +39,7 @@ class AppCtxGlobals(_AppCtxGlobals): @cached_property def current_user(self): - return session.get('user') if session.get('user') else {'username': 'Anon'} - - @cached_property - def current_wiki(self): - return Wiki(config.WIKI_PATH) + return current_user class Application(Flask): @@ -68,8 +64,8 @@ class Application(Flask): return super(Application, self).__call__(environ, start_response) def discover(self): - IMPORT_NAME = 'realms.modules' - FROMLIST = ( + import_name = 'realms.modules' + fromlist = ( 'assets', 'commands', 'models', @@ -78,10 +74,10 @@ class Application(Flask): start_time = time.time() - __import__(IMPORT_NAME, fromlist=FROMLIST) + __import__(import_name, fromlist=fromlist) 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 if hasattr(sources, 'views'): @@ -107,6 +103,18 @@ class Application(Flask): 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): """ 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') - -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): try: 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']: response = { 'message': message, - 'traceback': tb, + 'traceback': tb } else: response = render_template('errors/error.html', @@ -163,60 +157,67 @@ def error_handler(e): return response, status_code -for status_code in httplib.responses: - if status_code >= 400: - app.register_error_handler(status_code, error_handler) -from realms.lib.assets import register, assets -assets.init_app(app) -assets.app = app -assets.debug = config.DEBUG +def create_app(): + app = Application(__name__) + app.config.from_object('realms.config') + app.url_map.converters['regex'] = RegexConverter + app.url_map.strict_slashes = False -register('main', - 'vendor/jquery/jquery.js', - 'vendor/components-underscore/underscore.js', - 'vendor/components-bootstrap/js/bootstrap.js', - 'vendor/handlebars/handlebars.js', - 'vendor/showdown/src/showdown.js', - 'vendor/showdown/src/extensions/table.js', - 'js/wmd.js', - 'js/html-sanitizer-minified.js', # don't minify - 'vendor/highlightjs/highlight.pack.js', - 'vendor/parsleyjs/dist/parsley.js', - 'js/main.js') + for status_code in httplib.responses: + if status_code >= 400: + app.register_error_handler(status_code, error_handler) + @app.before_request + def init_g(): + g.assets = ['main'] -@app.before_request -def init_g(): - g.assets = ['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 -@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 + if config.RELATIVE_PATH: + @app.route("/") + def root(): + return redirect(url_for(config.ROOT_ENDPOINT)) + return app -@app.template_filter('datetime') -def _jinja2_filter_datetime(ts): - return time.strftime('%b %d, %Y %I:%M %p', time.localtime(ts)) +app = create_app() +# Init plugins here if possible +manager = Manager(app) -@app.errorhandler(404) -def page_not_found(e): - return render_template('errors/404.html'), 404 +login_manager = LoginManager(app) +login_manager.login_view = 'auth.login' +db = SQLAlchemy(app) +cache = Cache(app) -if config.RELATIVE_PATH: - @app.route("/") - def root(): - return redirect(url_for(config.ROOT_ENDPOINT)) - +assets = Environment(app) +assets.register('main', + 'vendor/jquery/jquery.js', + 'vendor/components-underscore/underscore.js', + 'vendor/components-bootstrap/js/bootstrap.js', + 'vendor/handlebars/handlebars.js', + 'vendor/showdown/src/showdown.js', + 'vendor/showdown/src/extensions/table.js', + 'js/wmd.js', + 'js/html-sanitizer-minified.js', # don't minify? + 'vendor/highlightjs/highlight.pack.js', + 'vendor/parsleyjs/dist/parsley.js', + 'js/main.js') app.discover() +db.create_all() + + + + + + diff --git a/realms/config/__init__.py b/realms/config/__init__.py index c7048aa..ed6f953 100644 --- a/realms/config/__init__.py +++ b/realms/config/__init__.py @@ -7,19 +7,35 @@ ENV = 'DEV' DEBUG = True ASSETS_DEBUG = True +SQLALCHEMY_ECHO = True + PORT = 80 BASE_URL = 'http://realms.dev' -REDIS_HOST = '127.0.0.1' -REDIS_PORT = 6379 -REDIS_DB = '0' +DB_URI = 'sqlite:////home/deploy/wiki.db' -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_HOME = 'home' - ALLOW_ANON = True +LOGIN_DISABLED = ALLOW_ANON ROOT_ENDPOINT = 'wiki.page' @@ -27,10 +43,11 @@ with open(os.path.join(os.path.dirname(__file__) + "/../../", 'config.json')) as __settings = json.load(f) globals().update(__settings) -# String trailing slash if BASE_URL.endswith('/'): BASE_URL = BASE_URL[-1] +SQLALCHEMY_DATABASE_URI = DB_URI + _url = urlparse(BASE_URL) RELATIVE_PATH = _url.path diff --git a/realms/lib/assets.py b/realms/lib/assets.py deleted file mode 100644 index 40e2efa..0000000 --- a/realms/lib/assets.py +++ /dev/null @@ -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)) diff --git a/realms/lib/model.py b/realms/lib/model.py new file mode 100644 index 0000000..015ae43 --- /dev/null +++ b/realms/lib/model.py @@ -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() \ No newline at end of file diff --git a/realms/lib/ratelimit.py b/realms/lib/ratelimit.py deleted file mode 100644 index cbdbbc4..0000000 --- a/realms/lib/ratelimit.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/realms/lib/services.py b/realms/lib/services.py deleted file mode 100644 index 94c7056..0000000 --- a/realms/lib/services.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/realms/lib/session.py b/realms/lib/session.py deleted file mode 100644 index cbd4d66..0000000 --- a/realms/lib/session.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/realms/lib/util.py b/realms/lib/util.py index 0c0ff1d..1106337 100644 --- a/realms/lib/util.py +++ b/realms/lib/util.py @@ -3,8 +3,6 @@ import os import hashlib import json -from realms.lib.services import db - class AttrDict(dict): def __init__(self, *args, **kwargs): @@ -34,36 +32,6 @@ def to_dict(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): if path and not(os.path.exists(path)): os.makedirs(path) diff --git a/realms/modules/auth/forms.py b/realms/modules/auth/forms.py index cd35fd5..1ba7eee 100644 --- a/realms/modules/auth/forms.py +++ b/realms/modules/auth/forms.py @@ -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): username = StringField('Username', [validators.Length(min=4, max=25)]) @@ -10,9 +11,12 @@ class RegistrationForm(Form): ]) confirm = PasswordField('Repeat Password') +if config.RECAPTCHA_ENABLE: + setattr(RegistrationForm, 'recaptcha', RecaptchaField("You Human?")) + class LoginForm(Form): - email = StringField('Email', [validators.DataRequired]) - password = PasswordField('Password', [validators.DataRequired]) + email = StringField('Email', [validators.DataRequired()]) + password = PasswordField('Password', [validators.DataRequired()]) diff --git a/realms/modules/auth/models.py b/realms/modules/auth/models.py index 733f8f8..86ce9a5 100644 --- a/realms/modules/auth/models.py +++ b/realms/modules/auth/models.py @@ -1,23 +1,15 @@ -from flask.ext.login import UserMixin, logout_user, login_user -from realms import config, login_manager -from realms.lib.services import db +from flask.ext.login import UserMixin, logout_user, login_user, AnonymousUserMixin +from realms import config, login_manager, db +from realms.lib.model import Model +from realms.lib.util import gravatar_url from itsdangerous import URLSafeSerializer, BadSignature from hashlib import sha256 -import json import bcrypt -FIELD_MAP = dict( - u='username', - e='email', - p='password', - nv='not_verified', - a='admin', - b='banned') - @login_manager.user_loader def load_user(user_id): - return User.get(user_id) + return User.get_by_id(user_id) @login_manager.token_loader @@ -29,7 +21,7 @@ def load_token(token): return False # 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: return False @@ -43,68 +35,78 @@ def load_token(token): return False -class User(UserMixin): +class AnonUser(AnonymousUserMixin): + username = 'Anon' + email = '' - username = None - email = None - password = None - def __init__(self, email, data=None): - self.id = email - for k, v in data.items(): - setattr(self, FIELD_MAP.get(k, k), v) +class User(Model, UserMixin): + __tablename__ = 'users' + id = db.Column(db.Integer, primary_key=True) + 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): 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 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 def signer(salt): """ Signed with app secret salted with sha256 of password hash of user (client secret) """ - return URLSafeSerializer(config.SECRET + 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 + return URLSafeSerializer(config.SECRET_KEY + salt) @staticmethod def auth(email, password): - user = User.get(email) + user = User.query.filter_by(email=email).first() if not user: + # User doesn't exist 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) return user else: + # Password check failed return False @staticmethod - def hash(password): - return bcrypt.hashpw(password, bcrypt.gensalt(log_rounds=12)) + def hash_password(password): + 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 def logout(cls): logout_user() +login_manager.anonymous_user = AnonUser \ No newline at end of file diff --git a/realms/modules/auth/views.py b/realms/modules/auth/views.py index 1dc4b86..caebad5 100644 --- a/realms/modules/auth/views.py +++ b/realms/modules/auth/views.py @@ -13,24 +13,55 @@ def logout_page(): return redirect(url_for(config.ROOT_ENDPOINT)) -@blueprint.route("/login") +@blueprint.route("/login", methods=['GET', 'POST']) def login(): - if request.method == "POST": - form = RegistrationForm() + form = LoginForm() - # TODO + if request.method == "POST": if not form.validate(): - flash('Form invalid') + flash('Form invalid', 'warning') return redirect(url_for('auth.login')) if User.auth(request.form['email'], request.form['password']): return redirect(request.args.get("next") or url_for(config.ROOT_ENDPOINT)) + else: + flash('Email or Password Incorrect', 'warning') + return redirect(url_for('auth.login')) - return render_template("auth/login.html") + return render_template("auth/login.html", form=form) -@blueprint.route("/register") + +@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)) - else: - return render_template("auth/register.html") \ No newline at end of file + + 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("/") \ No newline at end of file diff --git a/realms/modules/wiki/assets.py b/realms/modules/wiki/assets.py index 4595d20..b572460 100644 --- a/realms/modules/wiki/assets.py +++ b/realms/modules/wiki/assets.py @@ -1,8 +1,7 @@ -from realms.lib.assets import register +from realms import assets -register( - 'editor', - 'js/ace/ace.js', - 'js/ace/mode-markdown.js', - 'vendor/keymaster/keymaster.js', - 'js/dillinger.js') +assets.register('editor', + 'js/ace/ace.js', + 'js/ace/mode-markdown.js', + 'vendor/keymaster/keymaster.js', + 'js/dillinger.js') diff --git a/realms/lib/wiki.py b/realms/modules/wiki/models.py similarity index 87% rename from realms/lib/wiki.py rename to realms/modules/wiki/models.py index 8f6b87d..05fe692 100644 --- a/realms/lib/wiki.py +++ b/realms/modules/wiki/models.py @@ -7,7 +7,8 @@ import gittle.utils from gittle import Gittle from dulwich.repo import NotGitRepository 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): @@ -94,7 +95,7 @@ class Wiki(): content = lxml.html.tostring(tree, encoding='utf-8', method='html') - # post processing to fix errors + # remove added div tags content = content[5:-6] # 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) - 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: f.write(content) @@ -119,10 +121,14 @@ class Wiki(): if not email: email = self.default_committer_email - return self.repo.commit(name=username, - email=email, - message=message, - files=[filename]) + ret = self.repo.commit(name=username, + email=email, + message=message, + files=[filename]) + + cache.delete_memoized(Wiki.get_page, cname) + + return ret def rename_page(self, old_name, new_name): old_name, new_name = map(self.cname_to_filename, [old_name, new_name]) @@ -131,7 +137,10 @@ class Wiki(): email=self.default_committer_email, message="Moving %s to %s" % (old_name, new_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'): # commit = gittle.utils.git.commit_info(self.repo[sha]) name = self.cname_to_filename(name).encode('latin-1') @@ -151,5 +160,6 @@ class Wiki(): def get_history(self, 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" \ No newline at end of file diff --git a/realms/modules/wiki/tests.py b/realms/modules/wiki/tests.py deleted file mode 100644 index fedb4bd..0000000 --- a/realms/modules/wiki/tests.py +++ /dev/null @@ -1,5 +0,0 @@ -import realms - -c = realms.app.test_client() -print c.get('/wiki/_create') -print c.get('/wiki/_create/blah') \ No newline at end of file diff --git a/realms/modules/wiki/views.py b/realms/modules/wiki/views.py index d28e3dd..43128fa 100644 --- a/realms/modules/wiki/views.py +++ b/realms/modules/wiki/views.py @@ -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 import config +from realms.modules.wiki.models import Wiki +from realms import config, current_user blueprint = Blueprint('wiki', __name__, url_prefix=config.RELATIVE_PATH) +wiki = Wiki(config.WIKI_PATH) + @blueprint.route("/_commit//") def commit(name, sha): cname = to_canonical(name) - data = g.current_wiki.get_page(cname, sha=sha) + data = wiki.get_page(cname, sha=sha) if data: return render_template('wiki/page.html', name=name, page=data, commit=sha) else: @@ -18,41 +22,42 @@ def commit(name, sha): @blueprint.route("/_compare//") 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) @blueprint.route("/_revert", methods=['POST']) +@login_required def revert(): - if request.method == 'POST': - name = request.form.get('name') - commit = request.form.get('commit') - cname = to_canonical(name) - g.current_wiki.revert_page(name, commit, message="Reverting %s" % cname, - username=g.current_user.get('username')) - flash('Page reverted', 'success') - return redirect(url_for('wiki.page', name=cname)) + name = request.form.get('name') + commit = request.form.get('commit') + cname = to_canonical(name) + wiki.revert_page(name, commit, message="Reverting %s" % cname, + username=g.current_user.username) + flash('Page reverted', 'success') + return redirect(url_for('wiki.page', name=cname)) @blueprint.route("/_history/") 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')) @blueprint.route("/_edit/", methods=['GET', 'POST']) +@login_required def edit(name): - data = g.current_wiki.get_page(name) + data = wiki.get_page(name) cname = to_canonical(name) if request.method == 'POST': edit_cname = to_canonical(request.form['name']) 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, - request.form['content'], - message=request.form['message'], - username=g.current_user.get('username')) + wiki.write_page(edit_cname, + request.form['content'], + message=request.form['message'], + username=g.current_user.username) else: if data: name = remove_ext(data['name']) @@ -64,22 +69,24 @@ def edit(name): @blueprint.route("/_delete/", methods=['POST']) +@login_required def delete(name): pass @blueprint.route("/_create/", defaults={'name': None}, methods=['GET', 'POST']) @blueprint.route("/_create/", methods=['GET', 'POST']) +@login_required def create(name): if request.method == 'POST': - g.current_wiki.write_page(request.form['name'], - request.form['content'], - message=request.form['message'], - create=True, - username=g.current_user.get('username')) + wiki.write_page(request.form['name'], + request.form['content'], + message=request.form['message'], + create=True, + username=g.current_user.username) 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 return redirect(url_for('wiki.edit', name=cname)) @@ -94,7 +101,7 @@ def page(name): if cname != name: return redirect(url_for('wiki.page', name=cname)) - data = g.current_wiki.get_page(cname) + data = wiki.get_page(cname) if data: return render_template('wiki/page.html', name=cname, page=data) diff --git a/realms/static/css/style.css b/realms/static/css/style.css index a3eabd8..bc9771a 100644 --- a/realms/static/css/style.css +++ b/realms/static/css/style.css @@ -145,14 +145,8 @@ body { border-radius: 2px; } - -.navbar-nav .user-avatar a { - -} - -.navbar-nav>li.user-avatar a { - padding-top: 9px; - padding-bottom: 9px; +.navbar-nav>li.user-avatar img { + margin-right: 3px; } .floating-header { @@ -169,4 +163,41 @@ body { /* background-image: -webkit-linear-gradient(top, #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; +} + +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; } \ No newline at end of file diff --git a/realms/templates/auth/login.html b/realms/templates/auth/login.html index d2f1a89..db774c9 100644 --- a/realms/templates/auth/login.html +++ b/realms/templates/auth/login.html @@ -1,18 +1,8 @@ {% extends 'layout.html' %} +{% from 'macros.html' import render_form, render_field %} {% block body %} - -
-
- - -
- -
- - -
- - -
- -{% endblock %} \ No newline at end of file + {% call render_form(form, action_url=url_for('auth.login'), action_text='Login', btn_class='btn btn-primary') %} + {{ render_field(form.email, placeholder='Email', type='email', required=1) }} + {{ render_field(form.password, placeholder='Password', type='password', required=1) }} + {% endcall %} +{% endblock %} diff --git a/realms/templates/auth/register.html b/realms/templates/auth/register.html index e369803..065dc03 100644 --- a/realms/templates/auth/register.html +++ b/realms/templates/auth/register.html @@ -1,29 +1,13 @@ {% extends 'layout.html' %} +{% from 'macros.html' import render_form, render_field %} {% block body %} - -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- - - -
- + {% call render_form(form, action_url=url_for('auth.register'), action_text='Register', btn_class='btn btn-primary') %} + {{ render_field(form.username, placeholder='Username', type='username', **{"required": 1, "data-parsley-type": "alphanum"}) }} + {{ render_field(form.email, placeholder='Email', type='email', required=1) }} + {{ render_field(form.password, placeholder='Password', type='password', **{"required": 1, "data-parsley-minlength": "6"}) }} + {{ render_field(form.confirm, placeholder='Confirm Password', type='password', **{"required": 1, "data-parsley-minlength": "6"}) }} + {% if config.RECAPTCHA_ENABLE %} + {{ render_field(form.recaptcha) }} + {% endif %} + {% endcall %} {% endblock %} \ No newline at end of file diff --git a/realms/templates/auth/settings.html b/realms/templates/auth/settings.html new file mode 100644 index 0000000..a20a5e8 --- /dev/null +++ b/realms/templates/auth/settings.html @@ -0,0 +1,5 @@ +{% extends 'layout.html' %} +{% from 'macros.html' import render_form, render_field %} +{% block body %} + +{% endblock %} diff --git a/realms/templates/layout.html b/realms/templates/layout.html index 9aebc11..9a8a78d 100644 --- a/realms/templates/layout.html +++ b/realms/templates/layout.html @@ -43,16 +43,16 @@ {% endif %}