diff --git a/core/admin/mailu/__init__.py b/core/admin/mailu/__init__.py index 874fa8e0..8996fa20 100644 --- a/core/admin/mailu/__init__.py +++ b/core/admin/mailu/__init__.py @@ -1,44 +1,40 @@ import flask import flask_bootstrap -import os -import docker -import socket -import uuid - -from mailu import utils, debug, db +from mailu import utils, debug, models, configuration def create_app_from_config(config): """ Create a new application based on the given configuration """ app = flask.Flask(__name__) - app.config = config + app.app_context().push() # Bootstrap is used for basic JS and CSS loading # TODO: remove this and use statically generated assets instead app.bootstrap = flask_bootstrap.Bootstrap(app) # Initialize application extensions + config.init_app(app) models.db.init_app(app) utils.limiter.init_app(app) utils.babel.init_app(app) utils.login.init_app(app) + utils.login.user_loader(models.User.query.get) utils.proxy.init_app(app) - manage.migrate.init_app(app) - manage.manager.init_app(app) # Initialize debugging tools - if app.config.get("app.debug"): + if app.config.get("DEBUG"): debug.toolbar.init_app(app) - debug.profiler.init_app(app) + # TODO: add a specific configuration variable for profiling + # debug.profiler.init_app(app) # Inject the default variables in the Jinja parser + # TODO: move this to blueprints when needed @app.context_processor def inject_defaults(): signup_domains = models.Domain.query.filter_by(signup_enabled=True).all() return dict( - current_user=utils.login.current_user, signup_domains=signup_domains, config=app.config ) diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index 43184bad..05a3d570 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -4,7 +4,7 @@ import os DEFAULT_CONFIG = { # Specific to the admin UI 'SQLALCHEMY_DATABASE_URI': 'sqlite:////data/main.db', - 'SQLALCHEMY_TRACK_MODIFICATIONS': False, + 'SQLALCHEMY_TRACK_MODIFICATIONS': True, 'DOCKER_SOCKET': 'unix:///var/run/docker.sock', 'BABEL_DEFAULT_LOCALE': 'en', 'BABEL_DEFAULT_TIMEZONE': 'UTC', @@ -13,6 +13,7 @@ DEFAULT_CONFIG = { 'QUOTA_STORAGE_URL': 'redis://redis/1', 'DEBUG': False, 'DOMAIN_REGISTRATION': False, + 'TEMPLATES_AUTO_RELOAD': True, # Statistics management 'INSTANCE_ID_PATH': '/data/instance', 'STATS_ENDPOINT': '0.{}.stats.mailu.io', @@ -58,13 +59,29 @@ class ConfigManager(object): """ def __init__(self): - self.config = { - os.environ.get(key, value) + self.config = dict() + + def init_app(self, app): + self.config.update(app.config) + self.config.update({ + key: os.environ.get(key, value) for key, value in DEFAULT_CONFIG.items() - } + }) + app.config = self + + def setdefault(self, key, value): + if key not in self.config: + self.config[key] = value + return self.config[key] def get(self, *args): return self.config.get(*args) def __getitem__(self, key): - return self.get(key) + return self.config.get(key) + + def __setitem__(self, key, value): + self.config[key] = value + + def __contains__(self, key): + return key in self.config diff --git a/core/admin/mailu/debug.py b/core/admin/mailu/debug.py index 69e88cf8..7677901b 100644 --- a/core/admin/mailu/debug.py +++ b/core/admin/mailu/debug.py @@ -9,7 +9,7 @@ toolbar = flask_debugtoolbar.DebugToolbarExtension() # Profiler class Profiler(object): - def init_app(self): + def init_app(self, app): app.wsgi_app = werkzeug_profiler.ProfilerMiddleware( app.wsgi_app, restrictions=[30] ) diff --git a/core/admin/mailu/manage.py b/core/admin/mailu/manage.py index 2defa9d6..e0aa6286 100644 --- a/core/admin/mailu/manage.py +++ b/core/admin/mailu/manage.py @@ -14,12 +14,13 @@ db = models.db @click.group() -def cli(cls=flask_cli.FlaskGroup, create_app=mailu.create_app): +def cli(cls=flask_cli.FlaskGroup, create_app=create_app): """ Main command group """ @cli.command() +@flask_cli.with_appcontext def advertise(): """ Advertise this server against statistic services. """ @@ -38,9 +39,10 @@ def advertise(): @cli.command() -@cli.argument('localpart', help='localpart for the new admin') -@cli.argument('domain_name', help='domain name for the new admin') -@cli.argument('password', help='plain password for the new admin') +@click.argument('localpart') +@click.argument('domain_name') +@click.argument('password') +@flask_cli.with_appcontext def admin(localpart, domain_name, password): """ Create an admin user """ @@ -59,14 +61,16 @@ def admin(localpart, domain_name, password): @cli.command() -@cli.argument('localpart', help='localpart for the new user') -@cli.argument('domain_name', help='domain name for the new user') -@cli.argument('password', help='plain password for the new user') -@cli.argument('hash_scheme', help='password hashing scheme') -def user(localpart, domain_name, password, - hash_scheme=app.config['PASSWORD_SCHEME']): +@click.argument('localpart') +@click.argument('domain_name') +@click.argument('password') +@click.argument('hash_scheme') +@flask_cli.with_appcontext +def user(localpart, domain_name, password, hash_scheme=None): """ Create a user """ + if hash_scheme is None: + hash_scheme = app.config['PASSWORD_SCHEME'] domain = models.Domain.query.get(domain_name) if not domain: domain = models.Domain(name=domain_name) @@ -82,10 +86,11 @@ def user(localpart, domain_name, password, @cli.command() -@cli.option('-n', '--domain_name', dest='domain_name') -@cli.option('-u', '--max_users', dest='max_users') -@cli.option('-a', '--max_aliases', dest='max_aliases') -@cli.option('-q', '--max_quota_bytes', dest='max_quota_bytes') +@click.option('-n', '--domain_name') +@click.option('-u', '--max_users') +@click.option('-a', '--max_aliases') +@click.option('-q', '--max_quota_bytes') +@flask_cli.with_appcontext def domain(domain_name, max_users=0, max_aliases=0, max_quota_bytes=0): domain = models.Domain.query.get(domain_name) if not domain: @@ -95,14 +100,16 @@ def domain(domain_name, max_users=0, max_aliases=0, max_quota_bytes=0): @cli.command() -@cli.argument('localpart', help='localpart for the new user') -@cli.argument('domain_name', help='domain name for the new user') -@cli.argument('password_hash', help='password hash for the new user') -@cli.argument('hash_scheme', help='password hashing scheme') -def user_import(localpart, domain_name, password_hash, - hash_scheme=app.config['PASSWORD_SCHEME']): +@click.argument('localpart') +@click.argument('domain_name') +@click.argument('password_hash') +@click.argument('hash_scheme') +@flask_cli.with_appcontext +def user_import(localpart, domain_name, password_hash, hash_scheme = None): """ Import a user along with password hash. """ + if hash_scheme is None: + hash_scheme = app.config['PASSWORD_SCHEME'] domain = models.Domain.query.get(domain_name) if not domain: domain = models.Domain(name=domain_name) @@ -118,8 +125,9 @@ def user_import(localpart, domain_name, password_hash, @cli.command() -@cli.option('-v', dest='verbose') -@cli.option('-d', dest='delete_objects') +@click.option('-v', '--verbose') +@click.option('-d', '--delete_objects') +@flask_cli.with_appcontext def config_update(verbose=False, delete_objects=False): """sync configuration with data from YAML-formatted stdin""" import yaml @@ -259,7 +267,8 @@ def config_update(verbose=False, delete_objects=False): @cli.command() -@cli.argument('email', help='email address to be deleted') +@click.argument('email') +@flask_cli.with_appcontext def user_delete(email): """delete user""" user = models.User.query.get(email) @@ -269,7 +278,8 @@ def user_delete(email): @cli.command() -@cli.argument('email', help='email alias to be deleted') +@click.argument('email') +@flask_cli.with_appcontext def alias_delete(email): """delete alias""" alias = models.Alias.query.get(email) @@ -279,9 +289,10 @@ def alias_delete(email): @cli.command() -@cli.argument('localpart', help='localpart for the new alias') -@cli.argument('domain_name', help='domain name for the new alias') -@cli.argument('destination', help='destination for the new alias') +@click.argument('localpart') +@click.argument('domain_name') +@click.argument('destination') +@flask_cli.with_appcontext def alias(localpart, domain_name, destination): """ Create an alias """ @@ -300,10 +311,11 @@ def alias(localpart, domain_name, destination): @cli.command() -@cli.argument('domain_name', help='domain to be updated') -@cli.argument('max_users', help='maximum user count') -@cli.argument('max_aliases', help='maximum alias count') -@cli.argument('max_quota_bytes', help='maximum quota bytes par user') +@click.argument('domain_name') +@click.argument('max_users') +@click.argument('max_aliases') +@click.argument('max_quota_bytes') +@flask_cli.with_appcontext def setlimits(domain_name, max_users, max_aliases, max_quota_bytes): """ Set domain limits """ @@ -316,8 +328,9 @@ def setlimits(domain_name, max_users, max_aliases, max_quota_bytes): @cli.command() -@cli.argument('domain_name', help='target domain name') -@cli.argument('user_name', help='username inside the target domain') +@click.argument('domain_name') +@click.argument('user_name') +@flask_cli.with_appcontext def setmanager(domain_name, user_name='manager'): """ Make a user manager of a domain """ @@ -327,3 +340,6 @@ def setmanager(domain_name, user_name='manager'): db.session.add(domain) db.session.commit() + +if __name__ == '__main__': + cli() diff --git a/core/admin/mailu/models.py b/core/admin/mailu/models.py index 43565c55..bc1044db 100644 --- a/core/admin/mailu/models.py +++ b/core/admin/mailu/models.py @@ -307,24 +307,28 @@ class User(Base, Email): 'SHA256-CRYPT': "sha256_crypt", 'MD5-CRYPT': "md5_crypt", 'CRYPT': "des_crypt"} - pw_context = context.CryptContext( - schemes = scheme_dict.values(), - default=scheme_dict[app.config['PASSWORD_SCHEME']], - ) + + def get_password_context(self): + return context.CryptContext( + schemes=self.scheme_dict.values(), + default=self.scheme_dict[app.config['PASSWORD_SCHEME']], + ) def check_password(self, password): reference = re.match('({[^}]+})?(.*)', self.password).group(2) - return User.pw_context.verify(password, reference) + return self.get_password_context().verify(password, reference) - def set_password(self, password, hash_scheme=app.config['PASSWORD_SCHEME'], raw=False): + def set_password(self, password, hash_scheme=None, raw=False): """Set password for user with specified encryption scheme @password: plain text password to encrypt (if raw == True the hash itself) """ + if hash_scheme is None: + hash_scheme = app.config['PASSWORD_SCHEME'] # for the list of hash schemes see https://wiki2.dovecot.org/Authentication/PasswordSchemes if raw: self.password = '{'+hash_scheme+'}' + password else: - self.password = '{'+hash_scheme+'}' + User.pw_context.encrypt(password, self.scheme_dict[hash_scheme]) + self.password = '{'+hash_scheme+'}' + self.get_password_context().encrypt(password, self.scheme_dict[hash_scheme]) def get_managed_domains(self): if self.global_admin: diff --git a/core/admin/mailu/ui/views/alternatives.py b/core/admin/mailu/ui/views/alternatives.py index e5761c83..2e6e580b 100644 --- a/core/admin/mailu/ui/views/alternatives.py +++ b/core/admin/mailu/ui/views/alternatives.py @@ -1,4 +1,4 @@ -from mailu import db, models +from mailu import models from mailu.ui import ui, forms, access import flask diff --git a/core/admin/mailu/utils.py b/core/admin/mailu/utils.py index 60902951..18292041 100644 --- a/core/admin/mailu/utils.py +++ b/core/admin/mailu/utils.py @@ -1,3 +1,5 @@ +from mailu import models + import flask import flask_login import flask_script @@ -5,13 +7,14 @@ import flask_migrate import flask_babel import flask_limiter +from werkzeug.contrib import fixers + # Login configuration login = flask_login.LoginManager() login.login_view = "ui.login" -login.user_loader(models.User.query.get) -@login_manager.unauthorized_handler +@login.unauthorized_handler def handle_needs_login(): return flask.redirect( flask.url_for('ui.login', next=flask.request.endpoint) diff --git a/core/admin/manage.py b/core/admin/manage.py deleted file mode 100644 index f7e9e17d..00000000 --- a/core/admin/manage.py +++ /dev/null @@ -1,298 +0,0 @@ -from mailu import app, manager, db, models - -import os -import socket -import uuid - - -@manager.command -def advertise(): - """ Advertise this server against statistic services. - """ - if os.path.isfile(app.config["INSTANCE_ID_PATH"]): - with open(app.config["INSTANCE_ID_PATH"], "r") as handle: - instance_id = handle.read() - else: - instance_id = str(uuid.uuid4()) - with open(app.config["INSTANCE_ID_PATH"], "w") as handle: - handle.write(instance_id) - if app.config["DISABLE_STATISTICS"].lower() != "true": - try: - socket.gethostbyname(app.config["STATS_ENDPOINT"].format(instance_id)) - except: - pass - - -@manager.command -def admin(localpart, domain_name, password): - """ Create an admin user - """ - domain = models.Domain.query.get(domain_name) - if not domain: - domain = models.Domain(name=domain_name) - db.session.add(domain) - user = models.User( - localpart=localpart, - domain=domain, - global_admin=True - ) - user.set_password(password) - db.session.add(user) - db.session.commit() - - -@manager.command -def user(localpart, domain_name, password, - hash_scheme=app.config['PASSWORD_SCHEME']): - """ Create a user - """ - domain = models.Domain.query.get(domain_name) - if not domain: - domain = models.Domain(name=domain_name) - db.session.add(domain) - user = models.User( - localpart=localpart, - domain=domain, - global_admin=False - ) - user.set_password(password, hash_scheme=hash_scheme) - db.session.add(user) - db.session.commit() - - -@manager.option('-n', '--domain_name', dest='domain_name') -@manager.option('-u', '--max_users', dest='max_users') -@manager.option('-a', '--max_aliases', dest='max_aliases') -@manager.option('-q', '--max_quota_bytes', dest='max_quota_bytes') -def domain(domain_name, max_users=0, max_aliases=0, max_quota_bytes=0): - domain = models.Domain.query.get(domain_name) - if not domain: - domain = models.Domain(name=domain_name) - db.session.add(domain) - db.session.commit() - - -@manager.command -def user_import(localpart, domain_name, password_hash, - hash_scheme=app.config['PASSWORD_SCHEME']): - """ Import a user along with password hash. Available hashes: - 'SHA512-CRYPT' - 'SHA256-CRYPT' - 'MD5-CRYPT' - 'CRYPT' - """ - domain = models.Domain.query.get(domain_name) - if not domain: - domain = models.Domain(name=domain_name) - db.session.add(domain) - user = models.User( - localpart=localpart, - domain=domain, - global_admin=False - ) - user.set_password(password_hash, hash_scheme=hash_scheme, raw=True) - db.session.add(user) - db.session.commit() - - -@manager.command -def config_update(verbose=False, delete_objects=False): - """sync configuration with data from YAML-formatted stdin""" - import yaml - import sys - new_config = yaml.load(sys.stdin) - # print new_config - domains = new_config.get('domains', []) - tracked_domains = set() - for domain_config in domains: - if verbose: - print(str(domain_config)) - domain_name = domain_config['name'] - max_users = domain_config.get('max_users', 0) - max_aliases = domain_config.get('max_aliases', 0) - max_quota_bytes = domain_config.get('max_quota_bytes', 0) - tracked_domains.add(domain_name) - domain = models.Domain.query.get(domain_name) - if not domain: - domain = models.Domain(name=domain_name, - max_users=max_users, - max_aliases=max_aliases, - max_quota_bytes=max_quota_bytes) - db.session.add(domain) - print("Added " + str(domain_config)) - else: - domain.max_users = max_users - domain.max_aliases = max_aliases - domain.max_quota_bytes = max_quota_bytes - db.session.add(domain) - print("Updated " + str(domain_config)) - - users = new_config.get('users', []) - tracked_users = set() - user_optional_params = ('comment', 'quota_bytes', 'global_admin', - 'enable_imap', 'enable_pop', 'forward_enabled', - 'forward_destination', 'reply_enabled', - 'reply_subject', 'reply_body', 'displayed_name', - 'spam_enabled', 'email', 'spam_threshold') - for user_config in users: - if verbose: - print(str(user_config)) - localpart = user_config['localpart'] - domain_name = user_config['domain'] - password_hash = user_config.get('password_hash', None) - hash_scheme = user_config.get('hash_scheme', None) - domain = models.Domain.query.get(domain_name) - email = '{0}@{1}'.format(localpart, domain_name) - optional_params = {} - for k in user_optional_params: - if k in user_config: - optional_params[k] = user_config[k] - if not domain: - domain = models.Domain(name=domain_name) - db.session.add(domain) - user = models.User.query.get(email) - tracked_users.add(email) - tracked_domains.add(domain_name) - if not user: - user = models.User( - localpart=localpart, - domain=domain, - **optional_params - ) - else: - for k in optional_params: - setattr(user, k, optional_params[k]) - user.set_password(password_hash, hash_scheme=hash_scheme, raw=True) - db.session.add(user) - - aliases = new_config.get('aliases', []) - tracked_aliases = set() - for alias_config in aliases: - if verbose: - print(str(alias_config)) - localpart = alias_config['localpart'] - domain_name = alias_config['domain'] - if type(alias_config['destination']) is str: - destination = alias_config['destination'].split(',') - else: - destination = alias_config['destination'] - wildcard = alias_config.get('wildcard', False) - domain = models.Domain.query.get(domain_name) - email = '{0}@{1}'.format(localpart, domain_name) - if not domain: - domain = models.Domain(name=domain_name) - db.session.add(domain) - alias = models.Alias.query.get(email) - tracked_aliases.add(email) - tracked_domains.add(domain_name) - if not alias: - alias = models.Alias( - localpart=localpart, - domain=domain, - wildcard=wildcard, - destination=destination, - email=email - ) - else: - alias.destination = destination - alias.wildcard = wildcard - db.session.add(alias) - - db.session.commit() - - managers = new_config.get('managers', []) - # tracked_managers=set() - for manager_config in managers: - if verbose: - print(str(manager_config)) - domain_name = manager_config['domain'] - user_name = manager_config['user'] - domain = models.Domain.query.get(domain_name) - manageruser = models.User.query.get(user_name + '@' + domain_name) - if manageruser not in domain.managers: - domain.managers.append(manageruser) - db.session.add(domain) - - db.session.commit() - - if delete_objects: - for user in db.session.query(models.User).all(): - if not (user.email in tracked_users): - if verbose: - print("Deleting user: " + str(user.email)) - db.session.delete(user) - for alias in db.session.query(models.Alias).all(): - if not (alias.email in tracked_aliases): - if verbose: - print("Deleting alias: " + str(alias.email)) - db.session.delete(alias) - for domain in db.session.query(models.Domain).all(): - if not (domain.name in tracked_domains): - if verbose: - print("Deleting domain: " + str(domain.name)) - db.session.delete(domain) - db.session.commit() - - -@manager.command -def user_delete(email): - """delete user""" - user = models.User.query.get(email) - if user: - db.session.delete(user) - db.session.commit() - - -@manager.command -def alias_delete(email): - """delete alias""" - alias = models.Alias.query.get(email) - if alias: - db.session.delete(alias) - db.session.commit() - - -@manager.command -def alias(localpart, domain_name, destination): - """ Create an alias - """ - domain = models.Domain.query.get(domain_name) - if not domain: - domain = models.Domain(name=domain_name) - db.session.add(domain) - alias = models.Alias( - localpart=localpart, - domain=domain, - destination=destination.split(','), - email="%s@%s" % (localpart, domain_name) - ) - db.session.add(alias) - db.session.commit() - -# Set limits to a domain - - -@manager.command -def setlimits(domain_name, max_users, max_aliases, max_quota_bytes): - domain = models.Domain.query.get(domain_name) - domain.max_users = max_users - domain.max_aliases = max_aliases - domain.max_quota_bytes = max_quota_bytes - - db.session.add(domain) - db.session.commit() - -# Make the user manager of a domain - - -@manager.command -def setmanager(domain_name, user_name='manager'): - domain = models.Domain.query.get(domain_name) - manageruser = models.User.query.get(user_name + '@' + domain_name) - domain.managers.append(manageruser) - db.session.add(domain) - db.session.commit() - - -if __name__ == "__main__": - manager.run()