diff --git a/app/__init__.py b/app/__init__.py index f35cc0d..aeaf33c 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,81 +1,79 @@ # -*- coding: utf-8 -*- # # Copyright 2014 Kolab Systems AG (http://www.kolabsys.com) # # Thomas Bruederli # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # -from flask import Flask, session, request, g, render_template -from flask.ext.sqlalchemy import SQLAlchemy -from flask.ext.bootstrap import Bootstrap -from flask.ext.login import LoginManager -from flask.ext.babel import Babel +from flask import Flask, request, render_template +from flask_sqlalchemy import SQLAlchemy +from flask_bootstrap import Bootstrap +from flask_login import LoginManager +from flask_babel import Babel from config import config bootstrap = Bootstrap() db = SQLAlchemy() login_manager = LoginManager() login_manager.session_protection = 'strong' login_manager.login_view = 'auth.login' def create_app(config_name): app = Flask(__name__) app.config.from_object(config[config_name]) config[config_name].init_app(app) bootstrap.init_app(app) db.init_app(app) login_manager.init_app(app) babel = Babel(app) # initialize logging import logging.config logging.config.fileConfig(app.config['CONFIG_DIR'] + '/bonnie-flask.conf') # add main controller from views.root import root app.register_blueprint(root) # add (json) data controller from views.data import data app.register_blueprint(data) # add auth controller from auth import auth app.register_blueprint(auth, url_prefix='/auth') # add API controller from api import api app.register_blueprint(api, url_prefix='/api') # set locale from client headers @babel.localeselector def get_locale(): - return request.accept_languages.best_match(['de','fr','en']) + return request.accept_languages.best_match(['de', 'fr', 'en']) # render custom error pages @app.errorhandler(403) def forbidden(e): return render_template('403.html'), 403 @app.errorhandler(404) def pagenotfound(e): return render_template('404.html'), 404 return app - - \ No newline at end of file diff --git a/app/api/authentication.py b/app/api/authentication.py index e1b77d7..667e07e 100644 --- a/app/api/authentication.py +++ b/app/api/authentication.py @@ -1,94 +1,93 @@ # -*- coding: utf-8 -*- # # Copyright 2014 Kolab Systems AG (http://www.kolabsys.com) # # Thomas Bruederli # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # import hmac import hashlib from flask import g, request, current_app -from flask.ext.httpauth import HTTPBasicAuth +from flask_httpauth import HTTPBasicAuth from functools import wraps from errors import unauthorized, forbidden -from ..model import User, Permission, AnonymousUser -from . import api +from ..model import User, AnonymousUser auth = HTTPBasicAuth() @auth.verify_password def verify_password(username, password): if username == '': g.current_user = AnonymousUser() return True - user = User.query.filter_by(username = username).first() + user = User.query.filter_by(username=username).first() if not user: return False g.current_user = user return user.verify_password(password) @auth.error_handler def auth_error(): return unauthorized('Invalid credentials') def permission_required(permission): """ Permission check decorator """ def decorator(f): @wraps(f) def decorated_function(*args, **kw): if not g.current_user.can(permission): return forbidden('Insufficient permissions') return f(*args, **kw) return decorated_function return decorator def signature_required(): """ Request signature verification decorator """ def decorator(f): def decorated_function(*args, **kw): if not verify_request(request): return forbidden('Invalid Request Signature') return f(*args, **kw) return decorated_function return decorator def verify_request(request): # check for allowed source IP allowed_ips = current_app.config['API'].get('allow', '').split(',') if request.remote_addr in allowed_ips: return True user = g.current_user signature = request.headers.get('X-Request-Sign', None) if not user or signature is None: return False sign = hmac.new( key=user.secret.encode('utf8'), msg=request.headers.get('X-Request-User') + ':' + request.data, digestmod=hashlib.sha256 ).hexdigest() return signature == sign diff --git a/app/auth/__init__.py b/app/auth/__init__.py index 797d1db..b929b0f 100644 --- a/app/auth/__init__.py +++ b/app/auth/__init__.py @@ -1,18 +1,17 @@ from flask import Blueprint from functools import wraps from flask import abort -from flask.ext.login import current_user +from flask_login import current_user auth = Blueprint('auth', __name__) -from . import views def permission_required(permission): def decorator(f): @wraps(f) def decorated_function(*args, **kw): if not current_user.can(permission): abort(403) return f(*args, **kw) return decorated_function return decorator diff --git a/app/auth/views.py b/app/auth/views.py index d113e98..92f00b0 100644 --- a/app/auth/views.py +++ b/app/auth/views.py @@ -1,40 +1,49 @@ from flask import render_template, request, flash, redirect, url_for -from flask.ext.login import login_required, login_user, logout_user -from flask.ext.wtf import Form +from flask_login import login_required, login_user, logout_user +from flask_wtf import Form from wtforms import StringField, PasswordField, SubmitField from wtforms.validators import DataRequired, Length -from flask.ext.babel import gettext as _ +from flask_babel import gettext as _ from app.model import User, Permission from . import auth + class LoginForm(Form): - username = StringField(_("Username"), validators=[DataRequired(), Length(3,128)]) + username = StringField( + _("Username"), + validators=[DataRequired(), Length(3, 128)] + ) + password = PasswordField(_("Password"), validators=[DataRequired()]) submit = SubmitField(_("Log in")) -@auth.route('/login', methods=['GET','POST']) +@auth.route('/login', methods=['GET', 'POST']) def login(): form = LoginForm() if form.validate_on_submit(): user = User.query.filter_by(username=form.username.data).first() if user is not None and user.verify_password(form.password.data): if user.can(Permission.WEB_ACCESS): login_user(user) - return redirect(request.args.get('next') or url_for('root.index')) + + if 'next' in request.args: + return redirect(request.args.get('next')) + + return redirect(url_for('root.index')) + else: flash(_("User not permitted to access the web client")) else: flash(_("Invalid username or password")) return render_template('login.html', form=form) + @auth.route('/logout') @login_required def logout(): logout_user() flash(_("Session terminated")) return redirect(url_for('root.index')) - - \ No newline at end of file diff --git a/app/model/kolabobject.py b/app/model/kolabobject.py index 9cf0a2f..2e8f433 100644 --- a/app/model/kolabobject.py +++ b/app/model/kolabobject.py @@ -1,394 +1,425 @@ # -*- coding: utf-8 -*- # # Copyright 2014 Kolab Systems AG (http://www.kolabsys.com) # # Thomas Bruederli # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # import json import pytz import hashlib import datetime import logging from dateutil.parser import parse as parse_date from pykolab.xml.utils import compute_diff from collections import OrderedDict from email import message_from_string log = logging.getLogger('model') + class KolabObject(object): """ Base Model class for accessing Kolab Groupware Object data """ folder_type = 'unknown' x_kolab_type = 'application/x-vnd.kolab.*' def __init__(self, env={}): from flask import current_app from app.storage import instance as storage_instance self.env = env self.config = current_app.config self.storage = storage_instance def created(self, uid, mailbox, msguid=None): """ Provide created date and user """ changelog = self._object_changelog(uid, mailbox, msguid, 1) if changelog and len(changelog) > 0: for change in changelog: if change['op'] == 'APPEND': change['uid'] = uid change.pop('op', None) return change return False def lastmodified(self, uid, mailbox, msguid=None): """ Provide last change information """ changelog = self._object_changelog(uid, mailbox, msguid, -3) if changelog and len(changelog) > 0: for change in changelog: if change['op'] == 'APPEND': change['uid'] = uid change.pop('op', None) return change return False - def changelog(self, uid, mailbox, msguid=None): + def changelog(self, uid=None, mailbox=None, msguid=None): """ Full changelog """ changelog = self._object_changelog(uid, mailbox, msguid) if changelog: return dict(uid=uid, changes=changelog) return False def get(self, uid, rev, mailbox, msguid=None): """ Retrieve an old revision """ obj = self._get(uid, mailbox, msguid, rev) if obj is not None: return dict(uid=uid, rev=rev, xml=str(obj), mailbox=mailbox) return False def _get(self, uid, mailbox, msguid, rev): """ Get an old revision and return the pykolab.xml object """ obj = False - rec = self.storage.get_revision(uid, self._resolve_mailbox_uri(mailbox), msguid, rev) + rec = self.storage.get_revision( + uid, + self._resolve_mailbox_uri(mailbox), + msguid, + rev + ) if rec is not None: raw = self.storage.get_message_data(rec) try: - message = message_from_string(raw.encode('utf8','replace')) + message = message_from_string(raw.encode('utf8', 'replace')) obj = self._object_from_message(message) or False except Exception, e: - log.warning("Failed to parse mime message for UID %s @%s: %r", uid, rev, e) + log.warning( + "Failed to parse mime message for UID %s @%s: %r", + uid, + rev, + e + ) if obj is False: - log.warning("Failed to parse mime message for UID %s @%s", uid, rev) + log.warning( + "Failed to parse mime message for UID %s @%s", + uid, + rev + ) return obj def diff(self, uid, rev1, rev2, mailbox, msguid=None, instance=None): """ - Compare two revisions of an object and return a list of property changes + Compare two revisions of an object and return a list of property + changes """ rev_old = rev1 rev_new = rev2 if rev_old >= rev_new: raise ValueError("Invalid argument 'rev'") old = self._get(uid, mailbox, msguid, rev_old) - if old == False: - raise ValueError("Object %s @rev:%s not found" % (uid, str(rev_old))) + if old is False: + raise ValueError( + "Object %s @rev:%s not found" % ( + uid, + str(rev_old) + ) + ) new = self._get(uid, mailbox, msguid, rev_new) - if new == False: - raise ValueError("Object %s @rev:%s not found" % (uid, str(rev_new))) + if new is False: + raise ValueError( + "Object %s @rev:%s not found" % ( + uid, + str(rev_new) + ) + ) # compute diff for the requested recurrence instance if instance is not None and hasattr(old, 'get_instance') and hasattr(new, 'get_instance'): log.debug("Get recurrence instance %s for object %s", instance, uid) try: recurrence_date = datetime.datetime.strptime(str(instance), "%Y%m%dT%H%M%S") except: try: recurrence_date = datetime.datetime.strptime(str(instance), "%Y%m%d").date() except: raise ValueError("Invalid isntance identifier %r" % (instance)) _old = old.get_instance(recurrence_date) if _old == None: raise ValueError("Object instance %s-%s @rev:%s not found" % (uid, instance, str(rev_old))) old_dict = _old.to_dict() old_dict['recurrence'] = old.get_recurrence().to_dict() _new = new.get_instance(recurrence_date) if _new == None: raise ValueError("Object instance %s-%s @rev:%s not found" % (uid, instance, str(rev_new))) new_dict = _new.to_dict() new_dict['recurrence'] = new.get_recurrence().to_dict() else: old_dict = old.to_dict() new_dict = new.to_dict() # compute diff and compose result result = dict(uid=uid, rev=rev_new, changes=convert2primitives(compute_diff(old_dict, new_dict, False))) if instance is not None: result['instance'] = instance return result def rawdata(self, uid, mailbox, rev, msguid=None): """ Get the full message payload of an old revision """ rec = self.storage.get_revision(uid, self._resolve_mailbox_uri(mailbox), msguid, rev) if rec is not None: return self.storage.get_message_data(rec) return False def _object_from_message(self, message): """ To be implemented in derived classes """ return None def _object_changelog(self, uid, mailbox, msguid, limit=None): """ Query storage for changelog events related to the given (message) UID """ # this requires a user context if not self.env.has_key('REQUEST_USER') or not self.env['REQUEST_USER']: return None + print uid, mailbox, msguid, limit + # fetch event log from storage eventlog = self.storage.get_events(uid, self._resolve_mailbox_uri(mailbox), msguid, limit) + print eventlog + # convert logstash entries into a sane changelog event_op_map = { 'MessageNew': 'RECEIVE', 'MessageAppend': 'APPEND', 'MessageTrash': 'DELETE', 'MessageMove': 'MOVE', 'MessageRead': 'READ', 'FlagsSet': 'FLAGSET', 'FlagsClear': 'FLAGCLEAR', } last_append_uid = 0 result = [] if eventlog is not None: for _log in eventlog: # filter MessageTrash following a MessageAppend event (which is an update operation) if _log['event'] == 'MessageTrash' and last_append_uid > int(_log['uidset']): continue # remember last appended message uid if _log['event'] == 'MessageAppend' and _log.has_key('uidset'): last_append_uid = int(_log['uidset']) # compose log entry to return logentry = { 'rev': _log.get('revision', None), 'op': event_op_map.get(_log['event'], 'UNKNOWN'), 'mailbox': self._convert_mailbox_uri(_log.get('mailbox', None)) } try: timestamp = parse_date(_log['timestamp_utc']) logentry['date'] = timestamp.strftime("%Y-%m-%dT%H:%M:%SZ") except Exception, e: try: timestamp = parse_date(_log['timestamp']) logentry['date'] = timestamp.astimezone(pytz.utc).strftime("%Y-%m-%dT%H:%M:%SZ") except Exception, e: log.warning("Failed to parse timestamp %r: %r", _log['timestamp'], str(e)) logentry['date'] = _log['timestamp'] logentry['user'] = self._get_user_info(_log) if _log.has_key('flagNames'): logentry['flags'] = _log['flagNames'] result.append(logentry) return result def _resolve_username(self, user): """ Resovle the given username to the corresponding nsuniqueid from LDAP """ # find existing entry in our storage backend result = self.storage.get_user(username=user) if result and result.has_key('id'): # TODO: cache this lookup in memory? return result['id'] # fall-back: return md5 sum of the username to make usernames work as fields/keys in elasticsearch return hashlib.md5(user).hexdigest() def _get_user_info(self, rec): """ Return user information (name, email) related to the given log entry """ if rec.has_key('user_id'): # get real user name from rec['user_id'] user = self.storage.get_user(id=rec['user_id']) if user is not None: return "%(cn)s <%(user)s>" % user if rec.has_key('user'): return rec['user'] elif rec['event'] == 'MessageAppend' and rec['headers'].has_key('From'): # fallback to message headers return rec['headers']['From'][0] return 'unknown' def _resolve_mailbox_uri(self, mailbox): """ Convert the given mailbox string into an absolute URI regarding the context of the requesting user. """ # this requires a user context if not self.env.has_key('REQUEST_USER') or not self.env['REQUEST_USER']: return mailbox if mailbox is None: return None # mailbox already is an absolute path if mailbox.startswith('user/') or mailbox.startswith('shared/'): + print "mailbox absolute path already" return mailbox domain = '' user = self.env['REQUEST_USER'] if '@' in user: (user,_domain) = user.split('@', 1) domain = '@' + _domain owner = user path = '/' + mailbox # TODO: make this configurable or read from IMAP shared_prefix = 'Shared Folders/' others_prefix = 'Other Users/' imap_delimiter = '/' # case: shared folder if mailbox.startswith(shared_prefix): return mailbox[len(shared_prefix):] + domain # case: other users folder if mailbox.startswith(others_prefix): (owner, subpath) = mailbox[len(others_prefix):].split(imap_delimiter, 1) path = imap_delimiter + subpath if mailbox.upper() == 'INBOX': path = '' # default: personal namespace folder return 'user/' + owner + path + domain def _convert_mailbox_uri(self, mailbox): """ Convert the given absolute mailbox URI into a relative folder name regarding the context of the requesting user. """ if mailbox is None: return None # this requires a user context request_user = str(self.env.get('REQUEST_USER', '')).lower() # TODO: make this configurable or read from IMAP shared_prefix = 'Shared Folders' others_prefix = 'Other Users' imap_delimiter = '/' domain = '' if '@' in mailbox: (folder,domain) = mailbox.split('@', 1) else: folder = mailbox if folder.startswith('user/'): parts = folder.split(imap_delimiter, 2) if len(parts) > 2: (prefix,user,path) = parts else: (prefix,user) = parts path = '' if len(path) == 0: path = 'INBOX' if not (user + '@' + domain).lower() == request_user: folder = imap_delimiter.join([others_prefix, user, path]) else: folder = path elif folder.startswith('shared/'): folder = imap_delimiter.join([shared_prefix, folder]) return folder ##### Utility functions def convert2primitives(struct): """ Convert complex types like datetime into primitives which can be serialized into JSON """ out = None if isinstance(struct, datetime.datetime): tz = 'Z' if struct.tzinfo == pytz.utc else '%z' out = struct.strftime('%Y-%m-%dT%H:%M:%S' + tz) elif isinstance(struct, datetime.date): out = struct.strftime('%Y-%m-%d') elif isinstance(struct, list): out = [convert2primitives(x) for x in struct] elif isinstance(struct, OrderedDict): out = OrderedDict([(key,convert2primitives(struct[key])) for key in struct.keys()]) elif isinstance(struct, dict): out = dict(zip(struct.keys(), map(convert2primitives, struct.values()))) else: out = struct return out diff --git a/app/model/user.py b/app/model/user.py index 5529d82..41efde3 100644 --- a/app/model/user.py +++ b/app/model/user.py @@ -1,107 +1,113 @@ # -*- coding: utf-8 -*- # # Copyright 2014 Kolab Systems AG (http://www.kolabsys.com) # # Thomas Bruederli # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # -import hashlib from sqlalchemy import not_ from werkzeug.security import generate_password_hash, check_password_hash from itsdangerous import TimedJSONWebSignatureSerializer as Serializer -from flask.ext.login import UserMixin, AnonymousUserMixin -from flask.ext.babel import gettext as _ +from flask_login import UserMixin, AnonymousUserMixin +from flask_babel import gettext as _ from flask import current_app from .. import db, login_manager class Permission: API_ACCESS = 0x01 WEB_ACCESS = 0x02 ADMINISTRATOR = 0x80 class User(UserMixin, db.Model): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String(64), unique=True, index=True) username = db.Column(db.String(64), unique=True, index=True) password_hash = db.Column(db.String(128)) name = db.Column(db.String(64)) secret = db.Column(db.String(128)) permissions = db.Column(db.Integer) def __init__(self, **kwargs): super(User, self).__init__(**kwargs) self.validation_errors = [] @property def password(self): raise AttributeError('password is not a readable attribute') @password.setter def password(self, password): self.password_hash = generate_password_hash(password) def verify_password(self, password): return check_password_hash(self.password_hash, password) def generate_reset_token(self, expiration=3600): s = Serializer(current_app.config['SECRET_KEY'], expiration) return s.dumps({'reset': self.id}) def can(self, permission): return (self.permissions & permission) == permission def update(self, attrib): for key, value in attrib.iteritems(): if not key == 'id' and hasattr(self, key): setattr(self, key, value) - if attrib.has_key('password'): + if 'password' in attrib: self.password = attrib['password'] def validate(self): self.validation_errors = [] if self.username.strip() == '': self.validation_errors.append(_("Username must not be empty")) - elif User.query.filter(User.username==self.username, not_(User.id==self.id)).count() > 0: - self.validation_errors.append(_("Username already taken")) + else: + user = User.query.filter( + User.username == self.username, + not_(User.id == self.id) + ) + + if user.count() > 0: + self.validation_errors.append(_("Username already taken")) return len(self.validation_errors) == 0 def to_json(self): return { 'id': self.id, 'username': self.username, 'email': self.email, 'name': self.name, 'permissions': self.permissions, 'secret': self.secret, } class AnonymousUser(AnonymousUserMixin): def can(self, permissions): return False + login_manager.anonymous_user = AnonymousUser @login_manager.user_loader def load_user(user_id): return User.query.get(int(user_id)) diff --git a/app/storage/elasticsearch_storage.py b/app/storage/elasticsearch_storage.py index 21e81a1..8d26a46 100644 --- a/app/storage/elasticsearch_storage.py +++ b/app/storage/elasticsearch_storage.py @@ -1,281 +1,349 @@ # -*- coding: utf-8 -*- # # Copyright 2014 Kolab Systems AG (http://www.kolabsys.com) # # Thomas Bruederli # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # import logging import elasticsearch from flask import current_app from . import AbstractStorage conf = current_app.config log = logging.getLogger('storage.elasticsearch') + class ElasticseachStorage(AbstractStorage): def __init__(self, *args, **kw): elasticsearch_address = 'localhost:9200' - if conf['STORAGE'].has_key('elasticsearch_address'): + if 'elasticsearch_address' in conf['STORAGE']: elasticsearch_address = conf['STORAGE']['elasticsearch_address'] self.es = elasticsearch.Elasticsearch(elasticsearch_address) - def get_user(self, id=None, username=None): """ API for resolving usernames and reading user info """ # get user by ID if id is not None: log.debug("ES get user by ID: %s" % (id), level=8) return self.get(id, index='objects', doctype='user') # find existing entry in our storage backend result = self.select( - [ ('user', '=', user) ], + [('user', '=', user)], index='objects', doctype='user', sortby='@timestamp:desc', limit=1 ) log.debug("ES get user by name: %s; %r" % (username, result), level=8) if result and result['total'] > 0: return self._transform_result(result['hits'][0]) return None def get_folder(self, mailbox=None, user=None): """ API for finding an IMAP folder record """ # FIXME: revisit this implementation for new storage layout # FIXME: check ACL of this folder in regards of the given user - result = self.select( - [ ('uri', '=', mailbox) ], + [('name', '=', mailbox)], index='objects', doctype='folder', sortby='@timestamp:desc', limit=1 ) if result and result['total'] > 0: return self._transform_result(result['hits'][0]) return None - def get_events(self, msguid, objuid, mailbox, limit=None): + def get_events(self, objuid, mailbox, msguid, limit=None): """ API for querying event notifications """ # FIXME: this only fetches events from the given mailbox! # TODO: resolve this object trail through all folders, starting with # the current one folders = [] if isinstance(mailbox, dict): folder = mailbox else: folder = self.get_folder(mailbox) if folder is not None: folders.append(folder) if len(folders) > 0: result = [] folder_ids = [x['id'] for x in folders] - folder_names = dict((x['id'], x['name']) for x in folders) + # folder_names = dict((x['id'], x['name']) for x in folders) # set sorting and resultset size sortcol = '@timestamp' if limit is not None and limit < 0: sortcol = sortcol + ':desc' limit = abs(limit) - # search for events related to the given uid and the permitted folders - eventlog = self.storage.select( + # search for events related to the given uid and the permitted + # folders + eventlog = self.select( query=[ ('headers.Subject', '=', objuid), ('folder_id', '=', folder_ids) ], index='logstash-*', doctype='logs', sortby=sortcol, - fields='event,revision,headers,uidset,folder_id,user,user_id,@timestamp', + fields=[ + 'event', + 'revision', + 'headers', + 'uidset', + 'folder_id', + 'user', + 'user_id', + '@timestamp' + ].join(','), limit=limit ) if eventlog and eventlog['total'] > 0: result = eventlog['hits'] return result return None def get_revision(self, objuid, mailbox, msguid, rev): """ API to get a certain revision of a stored object """ - folder = mailbox if isinstance(mailbox, dict) else self.get_folder(mailbox) + if isinstance(mailbox, dict): + folder = mailbox + else: + folder = self.get_folder(mailbox) # retrieve the log entry matching the given uid and revision if folder is not None: results = self.storage.select( query=[ ('headers.Subject', '=', uid), ('folder_id', '=', folder['id']), ('revision', '=', rev) ], index='logstash-*', doctype='logs', fields='event,revision,uidset,folder_id,message', limit=1 ) if results and results['total'] > 0: return results['hits'][0] return None def get(self, key, index, doctype=None, fields=None, **kw): """ Standard API for accessing key/value storage """ try: res = self.es.get( index=index, doc_type=doctype, id=key, _source_include=fields or '*' ) - log.debug("ES get result for %s/%s/%s: %r" % (index, doctype, key, res), level=8) + + log.debug( + "ES get result for %s/%s/%s: %r" % ( + index, + doctype, + key, + res + ), + level=8 + ) if res['found']: result = res['_source'] result['_id'] = res['_id'] result['_index'] = res['_index'] result['_doctype'] = res['_type'] else: result = None except elasticsearch.exceptions.NotFoundError, e: - log.debug("ES entry not found for %s/%s/%s: %r" % (index, doctype, key, e)) + log.debug( + "ES entry not found for %s/%s/%s: %r" % ( + index, + doctype, + key, + e + ) + ) result = None except Exception, e: log.warning("ES get exception: %r", e) result = None return result def set(self, key, value, index, doctype=None, **kw): """ Standard API for writing to key/value storage """ return False - def select(self, query, index, doctype=None, fields=None, sortby=None, limit=None, **kw): + def select( + self, + query, + index, + doctype=None, + fields=None, + sortby=None, + limit=None, + **kw): """ Standard API for querying storage """ result = None - args = dict(index=index, doc_type=doctype, _source_include=fields or '*') + args = dict( + index=index, + doc_type=doctype, + _source_include=fields or '*' + ) if isinstance(query, dict): args['body'] = query elif isinstance(query, list): args['q'] = self._build_query(query) else: args['q'] = query if sortby is not None: args['sort'] = sortby if limit is not None: args['size'] = int(limit) try: res = self.es.search(**args) - log.debug("ES select result for %r: %r" % (args['q'] or args['body'], res), level=8) + log.debug( + "ES select result for %r: %r" % ( + args['q'] or args['body'], + res + ), + level=8 + ) except elasticsearch.exceptions.NotFoundError, e: - log.debug("ES entry not found for key %s: %r", key, e) + log.debug("ES entry not found for args %r: %r", args, e) res = None except Exception, e: log.warning("ES get exception: %r", e) res = None - if res is not None and res.has_key('hits'): + if res is not None and 'hits' in res: result = dict(total=res['hits']['total']) - result['hits'] = [self._transform_result(x) for x in res['hits']['hits']] + result['hits'] = [ + self._transform_result(x) for x in res['hits']['hits'] + ] + else: result = None return result def _build_query(self, params, boolean='AND'): """ - Convert the given list of query parameters into a Lucene query string + Convert the given list of query parameters into a Lucene query + string """ query = [] for p in params: if isinstance(p, str): # direct query string query.append(p) elif isinstance(p, tuple) and len(p) == 3: # triplet (field, op, value) = p op_ = '-' if op == '!=' else '' if isinstance(value, list): value_ = '("' + '","'.join(value) + '")' elif isinstance(value, tuple): value_ = '[%s TO %s]' % value else: - quote = '"' if not '*' in str(value) else '' + if '*' not in str(value): + quote = '"' + else: + quote = '' + value_ = quote + str(value) + quote query.append('%s%s:%s' % (op_, field, value_)) elif isinstance(p, tuple) and len(p) == 2: # group/subquery with boolean operator (op, subquery) = p query.append('(' + self._build_query(subquery, op) + ')') return (' '+boolean+' ').join(query) def _transform_result(self, res): """ Turn an elasticsearch result item into a simple dict """ - result = res['_source'] if res.has_key('_source') else dict() - result['id'] = res['_id'] + if '_source' in res: + result = res['_source'] + else: + result = dict() + + if '_id' in res: + result['id'] = res['_id'] + + if 'id' in res: + result['id'] = res['id'] + result['_index'] = res['_index'] - result['_doctype'] = res['_type'] + + if '_type' in res: + result['_doctype'] = res['_type'] + if '_doctype' in res: + result['_doctype'] = res['_doctype'] + result['_score'] = res['_score'] - if result.has_key('@timestamp'): + if '@timestamp' in result: result['timestamp'] = result['@timestamp'] result.pop('@timestamp', None) return result diff --git a/app/templates/base.html b/app/templates/base.html index c297508..b74ef1c 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,51 +1,51 @@ {% extends "bootstrap/base.html" %} {% block styles %} {{ super() }} {% endblock %} {% block title %}Bonnie{% endblock %} {% block navbar %} -{% if current_user.is_authenticated() %} +{% if current_user.is_authenticated %} {% endif %} {% endblock %} {% block content %}
{% for message in get_flashed_messages() %}
{{ message }}
{% endfor %}
{% block page_content %} [put page content here] {% endblock %}
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app/views/data.py b/app/views/data.py index 0cb5938..7226b54 100644 --- a/app/views/data.py +++ b/app/views/data.py @@ -1,142 +1,153 @@ # -*- coding: utf-8 -*- # # Copyright 2014 Kolab Systems AG (http://www.kolabsys.com) # # Thomas Bruederli # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # import json import logging from flask import Blueprint, current_app, request -from flask.ext.login import login_required, current_user +from flask_login import login_required, current_user from app import db from app.auth import auth, permission_required from app.model import User, Permission data = Blueprint('data', __name__, url_prefix='/data') log = logging.getLogger('data') + def jsonify(data): """ Similar to flask.jsonify but simply dumps the single argument as JSON """ - return current_app.response_class(json.dumps(data, indent=None if request.is_xhr else 2), - mimetype='application/json') + return current_app.response_class( + json.dumps(data, indent=None if request.is_xhr else 2), + mimetype='application/json' + ) + def abort(errcode, errmsg): - response = jsonify({ 'error': errmsg }) + response = jsonify({'error': errmsg}) response.status_code = errcode return response + def notfound(): return abort(404, 'Not Found') -@data.route('/users', methods=['GET','POST']) +@data.route('/users', methods=['GET', 'POST']) @login_required @permission_required(Permission.ADMINISTRATOR) def users(): result = [] # respond to GET request if request.method == 'GET': for user in User.query.all(): result.append(user.to_json()) elif request.method == 'POST': if hasattr(request, 'get_json'): save_data = request.get_json(True, True) else: save_data = request.json save_data.pop('password-check', None) try: user = User(**save_data) if user.validate(): db.session.add(user) db.session.commit() - result = { 'success': True, 'id': user.id } + result = {'success': True, 'id': user.id} else: - return abort(500, 'Validation error: ' + '; '.join(user.validation_errors)) + return abort( + 500, + 'Validation error: ' + '; '.join(user.validation_errors) + ) except Exception, e: log.error("Error creating user: %r; DATA=%r", e, save_data) return abort(500, 'Saving error') return jsonify(result) -@data.route('/users/', methods=['GET','PUT','DELETE']) +@data.route('/users/', methods=['GET', 'PUT', 'DELETE']) @login_required @permission_required(Permission.ADMINISTRATOR) def users_rec(id): result = {} user = db.session.query(User).get(id) if user is None: return notfound() # respond to GET request if request.method == 'GET': result = user.to_json() # flag current user if user.id == current_user.id: result['_isme'] = True # handle updates from PUT requests elif request.method == 'PUT': if hasattr(request, 'get_json'): save_data = request.get_json(True, True) else: save_data = request.json save_data.pop('password-check', None) # don't allow one to change its own permissions if user.id == current_user.id: save_data.pop('permissions', None) try: user.update(save_data) if user.validate(): db.session.add(user) db.session.commit() - result = { 'success': True } + result = {'success': True} else: - return abort(500, 'Validation error; ' + '; '.join(user.validation_errors)) + return abort( + 500, + 'Validation error; ' + '; '.join(user.validation_errors) + ) except Exception, e: log.error("Error saving user: %r; DATA=%r", e, save_data) return abort(500, 'Saving error') # handle DELETE requests elif request.method == 'DELETE': if user.id == current_user.id: log.warning("User tried to delete itself") return abort(403, 'Operation not permitted') try: db.session.delete(user) db.session.commit() - result = { 'success': True } + result = {'success': True} except Exception, e: log.error("Error deleting user: %r", e) return abort(500, 'Delete error') return jsonify(result) diff --git a/app/views/root.py b/app/views/root.py index 436e9b3..4dc81e8 100644 --- a/app/views/root.py +++ b/app/views/root.py @@ -1,39 +1,40 @@ # -*- coding: utf-8 -*- # # Copyright 2014 Kolab Systems AG (http://www.kolabsys.com) # # Thomas Bruederli # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # from flask import Blueprint, render_template -from flask.ext.login import login_required, current_user -from flask.ext.babel import gettext as _ +from flask_login import login_required, current_user +from flask_babel import gettext as _ -from app.auth import auth, permission_required +from app.auth import permission_required from ..model import Permission root = Blueprint('root', __name__) + @root.route('/') @login_required @permission_required(Permission.WEB_ACCESS) def index(): nav = {} # add admin tasks if current_user.can(Permission.ADMINISTRATOR): nav['users'] = _("Users") return render_template('window.html', mainnav=nav.items())