Mercurial > wikked
changeset 13:30ae685b86df
Added support for authentatication
- Added `.wikirc` config file.
- Added login, logout.
- Added users and groups.
- CLI command to generate user.
- CLI command to reset the index.
author | Ludovic Chabant <ludovic@chabant.com> |
---|---|
date | Sun, 30 Dec 2012 19:57:52 -0800 |
parents | ff0058feccf5 |
children | 0a0a98b97e9c |
files | manage.py wikked/__init__.py wikked/auth.py wikked/indexer.py wikked/static/css/wikked.less wikked/static/js/wikked.js wikked/static/tpl/login.html wikked/static/tpl/nav.html wikked/views.py wikked/wiki.py |
diffstat | 10 files changed, 306 insertions(+), 69 deletions(-) [+] |
line wrap: on
line diff
--- a/manage.py Sat Dec 29 18:24:38 2012 -0800 +++ b/manage.py Sun Dec 30 19:57:52 2012 -0800 @@ -1,13 +1,38 @@ import os.path -from flask.ext.script import Manager, Command -from wikked import app +import logging +from flask.ext.script import Manager, Command, prompt, prompt_pass +from wikked import app, wiki + manager = Manager(app) + @manager.command -def stats(): - """Prints some stats about the wiki.""" - pass +def users(): + """Lists users of this wiki.""" + print "Users:" + for user in wiki.auth.getUsers(): + print " - " + user.username + print "" + +@manager.command +def new_user(): + """Generates the entry for a new user so you can + copy/paste it in your `.wikirc`. + """ + username = prompt('Username: ') + password = prompt_pass('Password: ') + password = app.bcrypt.generate_password_hash(password) + print "[users]" + print "%s = %s" % (username, password) + + +@manager.command +def reset_index(): + """ Re-generates the index, if search is broken + somehow in your wiki. + """ + wiki.index.reset(wiki.getPages()) if __name__ == "__main__":
--- a/wikked/__init__.py Sat Dec 29 18:24:38 2012 -0800 +++ b/wikked/__init__.py Sun Dec 30 19:57:52 2012 -0800 @@ -1,4 +1,4 @@ -from flask import Flask +from flask import Flask, abort # Create the main app. app = Flask(__name__) @@ -12,16 +12,21 @@ '/': os.path.join(os.path.dirname(__file__), 'static') }) -# Login extension. -from flask.ext.login import LoginManager -login_manager = LoginManager() -login_manager.setup_app(app) - # The main Wiki instance. from wiki import Wiki wiki = Wiki(logger=app.logger) # Import views and user loader. import wikked.views -import wikked.auth +# Login extension. +from flask.ext.login import LoginManager +login_manager = LoginManager() +login_manager.init_app(app) +login_manager.user_loader(wiki.auth.getUser) +login_manager.unauthorized_handler(lambda: abort(401)) + +# Bcrypt extension. +from flaskext.bcrypt import Bcrypt +app.bcrypt = Bcrypt(app) +
--- a/wikked/auth.py Sat Dec 29 18:24:38 2012 -0800 +++ b/wikked/auth.py Sun Dec 30 19:57:52 2012 -0800 @@ -1,10 +1,11 @@ -from wikked import login_manager +import logging + class User(object): - - username = '' - password = '' - email = '' + def __init__(self, username, password): + self.username = username + self.password = password + self.groups = [] def is_authenticated(self): return True @@ -16,12 +17,46 @@ return False def get_id(self): - return str(self.username) + return unicode(self.username) + + def is_admin(self): + return 'administrators' in self.groups -@login_manager.user_loader -def load_user(userid): - try: - return User.objects.get(username=userid) - except: +class UserManager(object): + def __init__(self, config, logger=None): + if logger is None: + logger = logging.getLogger('wikked.auth') + self.logger = logger + self._updateUserInfos(config) + + def getUsers(self): + for user in self.users: + yield self._createUser(user) + + def getUser(self, username): + for user in self.users: + if user['username'] == username: + return self._createUser(user) return None + + def _updateUserInfos(self, config): + self.users = [] + if config.has_section('users'): + groups = [] + if config.has_section('groups'): + groups = config.items('groups') + + for user in config.items('users'): + user_info = { 'username': user[0], 'password': user[1], 'groups': [] } + for group in groups: + users_in_group = [u.strip() for u in group[1].split(',')] + if user[0] in users_in_group: + user_info['groups'].append(group[0]) + self.users.append(user_info) + + def _createUser(self, user_info): + user = User(user_info['username'], user_info['password']) + user.groups = list(user_info['groups']) + return user +
--- a/wikked/indexer.py Sat Dec 29 18:24:38 2012 -0800 +++ b/wikked/indexer.py Sun Dec 30 19:57:52 2012 -0800 @@ -25,17 +25,28 @@ WikiIndex.__init__(self, store_dir, logger) if not os.path.isdir(store_dir): os.makedirs(store_dir) - schema = Schema( - url=ID(stored=True), - title=TEXT(stored=True), - content=TEXT, - path=STORED, - time=STORED - ) - self.ix = create_in(store_dir, schema) + self.ix = create_in(store_dir, self._getSchema()) else: self.ix = open_dir(store_dir) + def _getSchema(self): + schema = Schema( + url=ID(stored=True), + title=TEXT(stored=True), + content=TEXT, + path=STORED, + time=STORED + ) + return schema + + def reset(self, pages): + self.ix = create_in(self.store_dir, schema=self._getSchema()) + writer = self.ix.writer() + for page in pages: + page._ensureMeta() + self._indexPage(writer, page) + writer.commit() + def update(self, pages): to_reindex = set() already_indexed = set()
--- a/wikked/static/css/wikked.less Sat Dec 29 18:24:38 2012 -0800 +++ b/wikked/static/css/wikked.less Sun Dec 30 19:57:52 2012 -0800 @@ -112,7 +112,7 @@ } .container { - nav, div.meta { + nav, .meta { color: @colorNavLink; font-size: @smallFontSize; line-height: (@baseLineHeight * 2); @@ -127,18 +127,16 @@ &:active { color: @colorNavLink; } } } - article { - .page { - .box-shadow(0 0 10px, rgb(210, 210, 210)); - padding: @baseLineHeight; - background: @backgroundPage; - } + .page { + .box-shadow(0 0 10px, rgb(210, 210, 210)); + padding: @baseLineHeight; + background: @backgroundPage; + } - .page>pre { - margin-top: @baseLineHeight; - } + .page>pre { + margin-top: @baseLineHeight; } - div.meta { + .meta { font-size: @smallerFontSize; } }
--- a/wikked/static/js/wikked.js Sat Dec 29 18:24:38 2012 -0800 +++ b/wikked/static/js/wikked.js Sun Dec 30 19:57:52 2012 -0800 @@ -18,12 +18,10 @@ loadedTemplates: {}, get: function(name, callback) { if (name in this.loadedTemplates) { - console.log('Returning cached template "{0}".'.format(name)); callback(this.loadedTemplates[name]); } else { var $loader = this; url = '/tpl/' + name + '.html' + '?' + (new Date()).getTime(); - console.log('Loading template "{0}" from: {1}'.format(name, url)); $.get(url, function(data) { $loader.loadedTemplates[name] = data; callback(data); @@ -118,7 +116,8 @@ defaults: function() { return { path: "main-page", - action: "read" + action: "read", + user: false }; }, initialize: function() { @@ -126,6 +125,10 @@ model._onChangePath(path); }); this._onChangePath(this.get('path')); + this.on('change:auth', function(model, auth) { + model._onChangeAuth(auth); + }); + this._onChangeAuth(this.get('auth')); return this; }, _onChangePath: function(path) { @@ -134,6 +137,17 @@ this.set('url_edit', '/#/edit/' + path); this.set('url_hist', '/#/changes/' + path); this.set('url_search', '/search'); + }, + _onChangeAuth: function(auth) { + if (auth) { + this.set('url_login', false); + this.set('url_logout', '/#/logout'); + this.set('username', auth.username); + } else { + this.set('url_login', '/#/login'); + this.set('url_logout', false); + this.set('username', false); + } } }); @@ -152,6 +166,18 @@ } }); + var LoginModel = Backbone.Model.extend({ + doLogin: function(form) { + $.post('/api/user/login', $(form).serialize()) + .success(function() { + app.navigate('/', { trigger: true }); + }) + .error(function() { + alert("Error while logging in..."); + }); + } + }); + var PageModel = Backbone.Model.extend({ idAttribute: 'path', defaults: function() { @@ -201,6 +227,9 @@ this.nav = new NavigationModel({ id: this.id }); this.footer = new FooterModel(); MasterPageModel.__super__.initialize.apply(this, arguments); + this.on('change:auth', function(model, auth) { + model._onChangeAuth(auth); + }); if (this.action !== undefined) { this.nav.set('action', this.action); this.footer.set('action', this.action); @@ -210,6 +239,9 @@ _onChangePath: function(path) { MasterPageModel.__super__._onChangePath.apply(this, arguments); this.nav.set('path', path); + }, + _onChangeAuth: function(auth) { + this.nav.set('auth', auth); } }); @@ -230,7 +262,17 @@ var PageEditModel = MasterPageModel.extend({ urlRoot: '/api/edit/', - action: 'edit' + action: 'edit', + doEdit: function(form) { + var path = this.get('path'); + $.post('/api/edit/' + path, $(form).serialize()) + .success(function(data) { + app.navigate('/read/' + path, { trigger: true }); + }) + .error(function() { + alert('Error saving page...'); + }); + } }); var PageHistoryModel = MasterPageModel.extend({ @@ -410,6 +452,24 @@ } }); + var LoginView = PageView.extend({ + templateName: 'login', + initialize: function() { + LoginView.__super__.initialize.apply(this, arguments); + this.render(); + return this; + }, + render: function() { + this.renderTemplate('login', function(view, model) { + this.$('form#login').submit(function() { + model.doLogin(this); + return false; + }); + }); + document.title = 'Login'; + } + }); + var MasterPageView = PageView.extend({ initialize: function() { MasterPageView.__super__.initialize.apply(this, arguments); @@ -471,21 +531,12 @@ renderCallback: function(view, model) { PageEditView.__super__.renderCallback.apply(this, arguments); this.$('#page-edit').submit(function() { - view._submitText(this, model.get('path')); + model.doEdit(this); return false; }); }, titleFormat: function(title) { return 'Editing: ' + title; - }, - _submitText: function(form, path) { - $.post('/api/edit/' + path, this.$(form).serialize()) - .success(function(data) { - app.navigate('/read/' + path, { trigger: true }); - }) - .error(function() { - alert('Error saving page...'); - }); } }); @@ -546,7 +597,9 @@ 'revision/*path/:rev': "readPageRevision", 'diff/c/*path/:rev': "showDiffWithPrevious", 'diff/r/*path/:rev1/:rev2':"showDiff", - 'search/:query': "showSearchResults" + 'search/:query': "showSearchResults", + 'login': "showLogin", + 'logout': "doLogout" }, readPage: function(path) { var view = new PageReadView({ @@ -622,6 +675,22 @@ view.model.execute(query); this.navigate('/search/' + query); }, + showLogin: function() { + var view = new LoginView({ + el: $('#app'), + model: new LoginModel() + }); + this.navigate('/login'); + }, + doLogout: function() { + $.post('/api/user/logout') + .success(function(data) { + app.navigate('/', { trigger: true }); + }) + .error(function() { + alert("Error logging out!"); + }); + }, getQueryVariable: function(variable) { var query = window.location.search.substring(1); var vars = query.split("&");
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/wikked/static/tpl/login.html Sun Dec 30 19:57:52 2012 -0800 @@ -0,0 +1,21 @@ +<div class="row"> + <div class="page span12"> + <h1>Login</h1> + <form id="login"> + <div class="control-group input-prepend"> + <label for="username" class="control-label add-on">Username: </label> + <input type="text" name="username" placeholder="Username..."></input> + </div> + <div class="control-group input-prepend"> + <label for="password" class="control-label add-on">Password: </label> + <input type="password" name="password" placeholder="Password..."></input> + </div> + <div class="control-group"> + <label>Remember Me + <input type="checkbox" name="remember"></input> + </label> + </div> + <button type="submit" class="btn btn-primary">Login</button> + </form> + </div> +</div>
--- a/wikked/static/tpl/nav.html Sat Dec 29 18:24:38 2012 -0800 +++ b/wikked/static/tpl/nav.html Sun Dec 30 19:57:52 2012 -0800 @@ -7,5 +7,11 @@ <input type="text" name="q" class="input-medium search-query" placeholder="Search..."> <button type="submit" class="btn">Search</button> </form> + {{#if username}} + <a href="{{url_profile}}">{{username}}</a> + <a href="{{url_logout}}">Logout</a> + {{else}} + <a href="{{url_login}}">Login</a> + {{/if}} </div> </nav>
--- a/wikked/views.py Sat Dec 29 18:24:38 2012 -0800 +++ b/wikked/views.py Sun Dec 30 19:57:52 2012 -0800 @@ -30,6 +30,15 @@ abort(404) +def make_auth_response(data): + if current_user.is_authenticated(): + data['auth'] = { + 'username': current_user.username, + 'is_admin': current_user.is_admin() + } + return jsonify(data) + + @app.route('/') def home(): return render_template('index.html', cache_bust=('?%d' % time.time())) @@ -54,21 +63,21 @@ def api_list_pages(url): page_metas = [page.all_meta for page in wiki.getPages(url)] result = { 'path': url, 'pages': list(page_metas) } - return jsonify(result) + return make_auth_response(result) @app.route('/api/read/<path:url>') def api_read_page(url): page = get_page_or_404(url) result = { 'path': url, 'meta': page.all_meta, 'text': page.formatted_text } - return jsonify(result) + return make_auth_response(result) @app.route('/api/raw/<path:url>') def api_read_page_raw(url): page = get_page_or_404(url) result = { 'path': url, 'meta': page.all_meta, 'text': page.raw_text } - return jsonify(result) + return make_auth_response(result) @app.route('/api/revision/<path:url>/<rev>') @@ -77,7 +86,7 @@ page_rev = page.getRevision(rev) meta = dict(page.all_meta, rev=rev) result = { 'path': url, 'meta': meta, 'text': page_rev } - return jsonify(result) + return make_auth_response(result) @app.route('/api/diff/<path:url>/<rev>') @@ -98,7 +107,7 @@ else: meta = dict(page.all_meta, rev1=rev1, rev2=rev2) result = { 'path': url, 'meta': meta, 'diff': diff } - return jsonify(result) + return make_auth_response(result) @app.route('/api/state/<path:url>') @@ -111,7 +120,7 @@ result = 'modified' elif state == scm.STATE_COMMITTED: result = 'committed' - return jsonify({ 'path': url, 'meta': page.all_meta, 'state': result }) + return make_auth_response({ 'path': url, 'meta': page.all_meta, 'state': result }) @app.route('/api/outlinks/<path:url>') @@ -129,7 +138,7 @@ links.append({ 'url': link, 'missing': True }) result = { 'path': url, 'meta': page.all_meta, 'out_links': links } - return jsonify(result) + return make_auth_response(result) @app.route('/api/inlinks/<path:url>') @@ -147,7 +156,7 @@ links.append({ 'url': link, 'missing': True }) result = { 'path': url, 'meta': page.all_meta, 'in_links': links } - return jsonify(result) + return make_auth_response(result) @app.route('/api/edit/<path:url>', methods=['GET', 'PUT', 'POST']) @@ -163,7 +172,7 @@ }, 'text': page.raw_text } - return jsonify(result) + return make_auth_response(result) if not 'text' in request.form: abort(400) @@ -182,7 +191,7 @@ } wiki.setPage(url, page_fields) result = { 'path': url, 'saved': 1 } - return jsonify(result) + return make_auth_response(result) @app.route('/api/rename/<path:url>', methods=['POST']) @@ -215,12 +224,61 @@ 'description': rev.description }) result = { 'url': url, 'meta': page.all_meta, 'history': hist_data } - return jsonify(result) + return make_auth_response(result) + @app.route('/api/search') def api_search(): query = request.args.get('q') hits = wiki.index.search(query) result = { 'query': query, 'hits': hits } - return jsonify(result) + return make_auth_response(result) + + +@app.route('/api/admin/reindex', methods=['POST']) +def api_admin_reindex(): + if not current_user.is_authenticated() or not current_user.is_admin(): + return login_manager.unauthorized() + wiki.index.reset(wiki.getPages()) + result = { 'ok': 1 } + return make_auth_response(result) + + +@app.route('/api/user/login', methods=['POST']) +def api_user_login(): + username = request.form.get('username') + password = request.form.get('password') + remember = request.form.get('remember') + user = wiki.auth.getUser(username) + if user is not None: + if app.bcrypt.check_password_hash(user.password, password): + login_user(user, remember=bool(remember)) + result = { 'username': username, 'logged_in': 1 } + return make_auth_response(result) + abort(401) + + +@app.route('/api/user/is_logged_in') +def api_user_is_logged_in(): + if current_user.is_authenticated(): + result = { 'logged_in': True } + return make_auth_response(result) + abort(401) + + +@app.route('/api/user/logout', methods=['POST']) +def api_user_logout(): + logout_user() + result = { 'ok': 1 } + return make_auth_response(result) + + +@app.route('/api/user/info/<name>') +def api_user_info(name): + user = wiki.auth.getUser(name) + if user is not None: + result = { 'username': user.username, 'groups': user.groups } + return make_auth_response(result) + abort(404) +
--- a/wikked/wiki.py Sat Dec 29 18:24:38 2012 -0800 +++ b/wikked/wiki.py Sun Dec 30 19:57:52 2012 -0800 @@ -4,11 +4,13 @@ import time import logging from itertools import chain +from ConfigParser import SafeConfigParser import markdown from fs import FileSystem from cache import Cache from scm import MercurialSourceControl from indexer import WhooshWikiIndex +from auth import UserManager class FormatterNotFound(Exception): @@ -204,11 +206,18 @@ self.logger = logging.getLogger('wikked.wiki') self.logger.debug("Initializing wiki at: " + root) + self.config = SafeConfigParser() + config_path = os.path.join(root, '.wikirc') + if os.path.isfile(config_path): + self.config.read(config_path) + self.fs = FileSystem(root) self.scm = MercurialSourceControl(root, self.logger) self.cache = None #Cache(os.path.join(root, '.cache')) self.index = WhooshWikiIndex(os.path.join(root, '.index'), logger=self.logger) + self.auth = UserManager(self.config, logger=self.logger) + self.fs.excluded.append(config_path) if self.cache is not None: self.fs.excluded.append(self.cache.cache_dir) if self.scm is not None: