diff --git a/core/admin/Dockerfile b/core/admin/Dockerfile index 8dda76d2..1958ae61 100644 --- a/core/admin/Dockerfile +++ b/core/admin/Dockerfile @@ -27,7 +27,7 @@ ENV TZ Etc/UTC # python3 shared with most images RUN set -eu \ - && apk add --no-cache python3 py3-pip git bash tzdata \ + && apk add --no-cache python3 py3-pip py3-wheel git bash tzdata \ && pip3 install --upgrade pip RUN mkdir -p /app @@ -37,13 +37,15 @@ COPY requirements-prod.txt requirements.txt RUN set -eu \ && apk add --no-cache libressl curl postgresql-libs mariadb-connector-c \ && apk add --no-cache --virtual build-dep libressl-dev libffi-dev python3-dev build-base postgresql-dev mariadb-connector-c-dev cargo \ - && pip3 install -r requirements.txt \ + && pip install --upgrade pip \ + && pip install -r requirements.txt \ && apk del --no-cache build-dep COPY --from=assets static ./mailu/static COPY mailu ./mailu COPY migrations ./migrations COPY start.py /start.py +COPY audit.py /audit.py RUN pybabel compile -d mailu/translations diff --git a/core/admin/assets/app.css b/core/admin/assets/app.css index 3886b5c1..84644900 100644 --- a/core/admin/assets/app.css +++ b/core/admin/assets/app.css @@ -52,3 +52,8 @@ fieldset:disabled .form-control:disabled { .select2-container--default .select2-selection--multiple .select2-selection__choice { color: black; } + +/* range input spacing */ +.input-group-text { + margin-right: 1em; +} diff --git a/core/admin/assets/app.js b/core/admin/assets/app.js index 54602d1f..03ea6215 100644 --- a/core/admin/assets/app.js +++ b/core/admin/assets/app.js @@ -18,7 +18,7 @@ $('document').ready(function() { $.post({ url: $(this).attr('href'), success: function() { - location.reload(); + window.location = window.location.href; }, }); }); @@ -28,10 +28,10 @@ $('document').ready(function() { var fieldset = $(this).parents('fieldset'); if (this.checked) { fieldset.removeAttr('disabled'); - fieldset.find('input').not(this).removeAttr('disabled'); + fieldset.find('input,textarea').not(this).removeAttr('disabled'); } else { fieldset.attr('disabled', ''); - fieldset.find('input').not(this).attr('disabled', ''); + fieldset.find('input,textarea').not(this).attr('disabled', ''); } }); @@ -43,7 +43,9 @@ $('document').ready(function() { var infinity = $(this).data('infinity'); var step = $(this).attr('step'); $(this).on('input', function() { - value_element.text((infinity && this.value == 0) ? '∞' : (this.value/step).toFixed(2)); + var num = (infinity && this.value == 0) ? '∞' : (this.value/step).toFixed(2); + if (num.endsWith('.00')) num = num.substr(0, num.length - 3); + value_element.text(num); }).trigger('input'); } }); diff --git a/core/admin/audit.py b/core/admin/audit.py old mode 100644 new mode 100755 index db105ff4..60583f83 --- a/core/admin/audit.py +++ b/core/admin/audit.py @@ -1,14 +1,19 @@ -from mailu import app +#!/usr/bin/python3 import sys import tabulate +sys.path[0:0] = ['/app'] + +import mailu +app = mailu.create_app() + # Known endpoints without permissions known_missing_permissions = [ - "index", - "static", "bootstrap.static", - "admin.static", "admin.login" + 'index', + 'static', 'bootstrap.static', + 'admin.static', 'admin.login' ] @@ -16,7 +21,7 @@ known_missing_permissions = [ missing_permissions = [] permissions = {} for endpoint, function in app.view_functions.items(): - audit = function.__dict__.get("_audit_permissions") + audit = function.__dict__.get('_audit_permissions') if audit: handler, args = audit if args: @@ -28,16 +33,15 @@ for endpoint, function in app.view_functions.items(): elif endpoint not in known_missing_permissions: missing_permissions.append(endpoint) - -# Fail if any endpoint is missing a permission check -if missing_permissions: - print("The following endpoints are missing permission checks:") - print(missing_permissions.join(",")) - sys.exit(1) - - # Display the permissions table print(tabulate.tabulate([ [route, *permissions[route.endpoint]] for route in app.url_map.iter_rules() if route.endpoint in permissions ])) + +# Warn if any endpoint is missing a permission check +if missing_permissions: + print() + print('The following endpoints are missing permission checks:') + print(','.join(missing_permissions)) + diff --git a/core/admin/mailu/__init__.py b/core/admin/mailu/__init__.py index e4024e47..fe1f376c 100644 --- a/core/admin/mailu/__init__.py +++ b/core/admin/mailu/__init__.py @@ -33,7 +33,7 @@ def create_app_from_config(config): app.srs_key = hmac.new(bytearray(app.secret_key, 'utf-8'), bytearray('SRS_KEY', 'utf-8'), 'sha256').digest() # Initialize list of translations - config.translations = { + app.config.translations = { str(locale): locale for locale in sorted( utils.babel.list_translations(), @@ -57,6 +57,15 @@ def create_app_from_config(config): config = app.config, ) + # Jinja filters + @app.template_filter() + def format_date(value): + return utils.flask_babel.format_date(value) if value else '' + + @app.template_filter() + def format_datetime(value): + return utils.flask_babel.format_datetime(value) if value else '' + # Import views from mailu import ui, internal, sso app.register_blueprint(ui.ui, url_prefix=app.config['WEB_ADMIN']) diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py index f4cf5c70..b60b8a3e 100644 --- a/core/admin/mailu/configuration.py +++ b/core/admin/mailu/configuration.py @@ -90,7 +90,7 @@ DEFAULT_CONFIG = { 'POD_ADDRESS_RANGE': None } -class ConfigManager(dict): +class ConfigManager: """ Naive configuration manager that uses environment only """ @@ -105,19 +105,16 @@ class ConfigManager(dict): def get_host_address(self, name): # if MYSERVICE_ADDRESS is defined, use this - if '{}_ADDRESS'.format(name) in os.environ: - return os.environ.get('{}_ADDRESS'.format(name)) + if f'{name}_ADDRESS' in os.environ: + return os.environ.get(f'{name}_ADDRESS') # otherwise use the host name and resolve it - return system.resolve_address(self.config['HOST_{}'.format(name)]) + return system.resolve_address(self.config[f'HOST_{name}']) def resolve_hosts(self): - self.config["IMAP_ADDRESS"] = self.get_host_address("IMAP") - self.config["POP3_ADDRESS"] = self.get_host_address("POP3") - self.config["AUTHSMTP_ADDRESS"] = self.get_host_address("AUTHSMTP") - self.config["SMTP_ADDRESS"] = self.get_host_address("SMTP") - self.config["REDIS_ADDRESS"] = self.get_host_address("REDIS") - if self.config["WEBMAIL"] != "none": - self.config["WEBMAIL_ADDRESS"] = self.get_host_address("WEBMAIL") + for key in ['IMAP', 'POP3', 'AUTHSMTP', 'SMTP', 'REDIS']: + self.config[f'{key}_ADDRESS'] = self.get_host_address(key) + if self.config['WEBMAIL'] != 'none': + self.config['WEBMAIL_ADDRESS'] = self.get_host_address('WEBMAIL') def __get_env(self, key, value): key_file = key + "_FILE" @@ -136,6 +133,7 @@ class ConfigManager(dict): return value def init_app(self, app): + # get current app config self.config.update(app.config) # get environment variables self.config.update({ @@ -149,9 +147,9 @@ class ConfigManager(dict): template = self.DB_TEMPLATES[self.config['DB_FLAVOR']] self.config['SQLALCHEMY_DATABASE_URI'] = template.format(**self.config) - self.config['RATELIMIT_STORAGE_URL'] = 'redis://{0}/2'.format(self.config['REDIS_ADDRESS']) - self.config['QUOTA_STORAGE_URL'] = 'redis://{0}/1'.format(self.config['REDIS_ADDRESS']) - self.config['SESSION_STORAGE_URL'] = 'redis://{0}/3'.format(self.config['REDIS_ADDRESS']) + self.config['RATELIMIT_STORAGE_URL'] = f'redis://{self.config["REDIS_ADDRESS"]}/2' + self.config['QUOTA_STORAGE_URL'] = f'redis://{self.config["REDIS_ADDRESS"]}/1' + self.config['SESSION_STORAGE_URL'] = f'redis://{self.config["REDIS_ADDRESS"]}/3' self.config['SESSION_COOKIE_SAMESITE'] = 'Strict' self.config['SESSION_COOKIE_HTTPONLY'] = True self.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=int(self.config['SESSION_LIFETIME'])) @@ -160,25 +158,7 @@ class ConfigManager(dict): self.config['MESSAGE_RATELIMIT_EXEMPTION'] = set([s for s in self.config['MESSAGE_RATELIMIT_EXEMPTION'].lower().replace(' ', '').split(',') if s]) self.config['HOSTNAMES'] = ','.join(hostnames) self.config['HOSTNAME'] = hostnames[0] - # update the app config itself - app.config = self - def setdefault(self, key, value): - if key not in self.config: - self.config[key] = value - return self.config[key] + # update the app config + app.config.update(self.config) - def get(self, *args): - return self.config.get(*args) - - def keys(self): - return self.config.keys() - - def __getitem__(self, 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 7677901b..4d63f3c5 100644 --- a/core/admin/mailu/debug.py +++ b/core/admin/mailu/debug.py @@ -1,6 +1,6 @@ import flask_debugtoolbar -from werkzeug.contrib import profiler as werkzeug_profiler +from werkzeug.middleware.profiler import ProfilerMiddleware # Debugging toolbar @@ -10,7 +10,7 @@ toolbar = flask_debugtoolbar.DebugToolbarExtension() # Profiler class Profiler(object): def init_app(self, app): - app.wsgi_app = werkzeug_profiler.ProfilerMiddleware( + app.wsgi_app = ProfilerMiddleware( app.wsgi_app, restrictions=[30] ) diff --git a/core/admin/mailu/models.py b/core/admin/mailu/models.py index f5fe3b5e..697e4df7 100644 --- a/core/admin/mailu/models.py +++ b/core/admin/mailu/models.py @@ -19,7 +19,8 @@ import os import hmac import smtplib import idna -import dns +import dns.resolver +import dns.exception from flask import current_app as app from sqlalchemy.ext import declarative @@ -38,6 +39,8 @@ class IdnaDomain(db.TypeDecorator): """ impl = db.String(80) + cache_ok = True + python_type = str def process_bind_param(self, value, dialect): """ encode unicode domain name to punycode """ @@ -47,13 +50,13 @@ class IdnaDomain(db.TypeDecorator): """ decode punycode domain name to unicode """ return idna.decode(value) - python_type = str - class IdnaEmail(db.TypeDecorator): """ Stores a Unicode string in it's IDNA representation (ASCII only) """ impl = db.String(255) + cache_ok = True + python_type = str def process_bind_param(self, value, dialect): """ encode unicode domain part of email address to punycode """ @@ -69,13 +72,13 @@ class IdnaEmail(db.TypeDecorator): localpart, domain_name = value.rsplit('@', 1) return f'{localpart}@{idna.decode(domain_name)}' - python_type = str - class CommaSeparatedList(db.TypeDecorator): """ Stores a list as a comma-separated string, compatible with Postfix. """ impl = db.String + cache_ok = True + python_type = list def process_bind_param(self, value, dialect): """ join list of items to comma separated string """ @@ -90,13 +93,13 @@ class CommaSeparatedList(db.TypeDecorator): """ split comma separated string to list """ return list(filter(bool, (item.strip() for item in value.split(',')))) if value else [] - python_type = list - class JSONEncoded(db.TypeDecorator): """ Represents an immutable structure as a json-encoded string. """ impl = db.String + cache_ok = True + python_type = str def process_bind_param(self, value, dialect): """ encode data as json """ @@ -106,8 +109,6 @@ class JSONEncoded(db.TypeDecorator): """ decode json to data """ return json.loads(value) if value else None - python_type = str - class Base(db.Model): """ Base class for all models """ diff --git a/core/admin/mailu/schemas.py b/core/admin/mailu/schemas.py index 191d01ac..00cbf464 100644 --- a/core/admin/mailu/schemas.py +++ b/core/admin/mailu/schemas.py @@ -145,6 +145,11 @@ class Logger: if history.has_changes() and history.deleted: before = history.deleted[-1] after = getattr(target, attr.key) + # we don't have ordered lists + if isinstance(before, list): + before = set(before) + if isinstance(after, list): + after = set(after) # TODO: this can be removed when comment is not nullable in model if attr.key == 'comment' and not before and not after: pass diff --git a/core/admin/mailu/sso/templates/form_sso.html b/core/admin/mailu/sso/templates/form_sso.html index b14e7600..d2451597 100644 --- a/core/admin/mailu/sso/templates/form_sso.html +++ b/core/admin/mailu/sso/templates/form_sso.html @@ -5,7 +5,7 @@
{%- endcall %} {%- endblock %} diff --git a/core/admin/mailu/sso/views/base.py b/core/admin/mailu/sso/views/base.py index 831949e7..390d5bbf 100644 --- a/core/admin/mailu/sso/views/base.py +++ b/core/admin/mailu/sso/views/base.py @@ -19,6 +19,7 @@ def login(): fields.append(form.submitAdmin) if str(app.config["WEBMAIL"]).upper() != "NONE": fields.append(form.submitWebmail) + fields = [fields] if form.validate_on_submit(): if form.submitAdmin.data: diff --git a/core/admin/mailu/ui/templates/alias/list.html b/core/admin/mailu/ui/templates/alias/list.html index 0b784d52..6b52165e 100644 --- a/core/admin/mailu/ui/templates/alias/list.html +++ b/core/admin/mailu/ui/templates/alias/list.html @@ -34,8 +34,8 @@