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: