From 4a954eb8e70bda5819f63da9cd841f1579573413 Mon Sep 17 00:00:00 2001 From: "Jannis M. Hoffmann" Date: Mon, 4 Dec 2023 23:15:38 +0100 Subject: add multi language urls update translations use redis as session store add requirements.txt file to pin dependencies add proper subfolder support improve compatibility with python 3.9 correct js for login focus --- src/jwebmail/__init__.py | 53 ++++++-- src/jwebmail/model/read_mails.py | 13 +- src/jwebmail/read_mails.py | 65 +++++++--- src/jwebmail/render_mail.py | 4 +- src/jwebmail/templates/_main_table.html | 4 +- src/jwebmail/templates/about.html | 6 +- src/jwebmail/templates/login.html | 8 +- .../translations/de/LC_MESSAGES/messages.po | 106 +++++++++------- src/jwebmail/webmail.py | 139 +++------------------ 9 files changed, 184 insertions(+), 214 deletions(-) (limited to 'src') diff --git a/src/jwebmail/__init__.py b/src/jwebmail/__init__.py index db0c796..b5279fb 100644 --- a/src/jwebmail/__init__.py +++ b/src/jwebmail/__init__.py @@ -1,8 +1,8 @@ -import os.path as ospath +import os.path as os_path import pwd import sys -from flask import Flask +from flask import Flask, g from flask_babel import Babel, get_locale from flask_login import LoginManager, login_required from jinja2 import ChainableUndefined @@ -21,6 +21,7 @@ from .webmail import ( readmail, sendmail, writemail, + DEFAULT_LANGUAGE, ) if sys.version_info >= (3, 11): @@ -37,9 +38,9 @@ def validate_config(app): assert "@" in conf["JWEBMAIL"]["ADMIN_MAIL"] assert pwd.getpwnam(conf["JWEBMAIL"]["READ_MAILS"]["MAILBOX_USER"]) - assert ospath.isdir(conf["JWEBMAIL"]["READ_MAILS"]["MAILBOX"]) - assert ospath.isfile(conf["JWEBMAIL"]["READ_MAILS"]["AUTHENTICATOR"]) - assert ospath.isfile(conf["JWEBMAIL"]["READ_MAILS"]["BACKEND"]) + assert os_path.isdir(conf["JWEBMAIL"]["READ_MAILS"]["MAILBOX"]) + assert os_path.isfile(conf["JWEBMAIL"]["READ_MAILS"]["AUTHENTICATOR"]) + assert os_path.isfile(conf["JWEBMAIL"]["READ_MAILS"]["BACKEND"]) def create_app(): @@ -49,7 +50,7 @@ def create_app(): app.config.from_file("../../jwebmail.toml", load=toml_load, text=False) validate_config(app) - Babel(app, locale_selector=lambda: "de") + Babel(app, locale_selector=lambda: g.get("lang_code", DEFAULT_LANGUAGE)) app.cli.add_command(compile_css_command) @@ -58,32 +59,62 @@ def create_app(): login_manager.user_loader(load_user) login_manager.init_app(app) + add_view_funcs(app) + route(app) + @app.context_processor def inject_version(): return {"version": "4.0", "get_locale": get_locale, "format_mail": format_mail} - add_view_funcs(app) - route(app) + @app.url_defaults + def add_language_code(endpoint, values): + if "lang_code" in values: + return + if app.url_map.is_endpoint_expecting(endpoint, "lang_code"): + values["lang_code"] = g.get("lang_code", DEFAULT_LANGUAGE) + + @app.url_value_preprocessor + def pull_lang_code(endpoint, values): + g.lang_code = ( + values.pop("lang_code", DEFAULT_LANGUAGE) if values else DEFAULT_LANGUAGE + ) return app def route(app): app.add_url_rule("/", view_func=login, methods=["GET", "POST"]) + app.add_url_rule("//", view_func=login, methods=["GET", "POST"]) app.add_url_rule("/about", view_func=about) + app.add_url_rule("//about", view_func=about) + app.add_url_rule("/logout", view_func=logout) + app.add_url_rule("//logout", view_func=logout) dh = login_required(displayheaders) app.add_url_rule("/home/", view_func=dh) app.add_url_rule("/home/", view_func=dh) + app.add_url_rule("//home/", view_func=dh) + app.add_url_rule("//home/", view_func=dh) + lr_readmail = login_required(readmail) + app.add_url_rule("/read/", endpoint="read", view_func=lr_readmail) + app.add_url_rule("/read//", endpoint="read", view_func=lr_readmail) app.add_url_rule( - "/read/", endpoint="read", view_func=login_required(readmail) + "//read/", endpoint="read", view_func=lr_readmail ) - app.add_url_rule("/raw/", endpoint="raw", view_func=login_required(rawmail)) + app.add_url_rule( + "//read//", endpoint="read", view_func=lr_readmail + ) + + lr_rawmail = login_required(rawmail) + app.add_url_rule("/raw/", endpoint="raw", view_func=rawmail) + app.add_url_rule("/raw//", endpoint="raw", view_func=rawmail) - app.add_url_rule("/write", endpoint="write", view_func=login_required(writemail)) + lr_writemail = login_required(writemail) + app.add_url_rule("/write", endpoint="write", view_func=lr_writemail) + app.add_url_rule("//write", endpoint="write", view_func=lr_writemail) app.add_url_rule( "/write", endpoint="send", view_func=login_required(sendmail), methods=["POST"] ) diff --git a/src/jwebmail/model/read_mails.py b/src/jwebmail/model/read_mails.py index f82b601..291fa1e 100644 --- a/src/jwebmail/model/read_mails.py +++ b/src/jwebmail/model/read_mails.py @@ -31,13 +31,12 @@ class QMailAuthuser: shell=True, timeout=2, ) - match completed_proc.returncode: - case 0: - return True - case 1: - return False - case n: - raise QMAuthError("authentication error", n) + if completed_proc.returncode == 0: + return True + if completed_proc.returncode == 1: + return False + else: + raise QMAuthError("authentication error", completed_proc.returncode) except TimeoutExpired: return False diff --git a/src/jwebmail/read_mails.py b/src/jwebmail/read_mails.py index e1c3b8c..915567c 100644 --- a/src/jwebmail/read_mails.py +++ b/src/jwebmail/read_mails.py @@ -1,11 +1,17 @@ -import dbm -import shelve - +import redis from flask import current_app, g -from flask_login import current_user +from flask_login import UserMixin, current_user from .model.read_mails import QMailAuthuser +EXPIRATION_SEC = 60 * 60 * 25 + + +class JWebmailUser(UserMixin): + def __init__(self, mail_addr, password): + self.id = mail_addr + self.password = password + def build_qma(username, password): authenticator = current_app.config["JWEBMAIL"]["READ_MAILS"]["AUTHENTICATOR"] @@ -22,29 +28,52 @@ def login(username, password): return build_qma(username, password).verify_user() -def add_user(user): - with shelve.open("user_sessions", flag="c") as s: - s[user.get_id()] = user +def add_user(user: JWebmailUser): + passwd = current_app.config["JWEBMAIL"]["READ_MAILS"]["SESSION_STORE_PASSWD"] + r = redis.Redis( + host="localhost", + port=6379, + decode_responses=True, + protocol=3, + username="jwebmail", + password=passwd, + ) + r.setex(f"jwm:user:{user.get_id()}", EXPIRATION_SEC, user.password) -def load_user(username): - try: - with shelve.open("user_sessions", flag="r") as s: - user = s[username] - return user - except dbm.error: - return None - except KeyError: +def load_user(username: str) -> JWebmailUser: + passwd = current_app.config["JWEBMAIL"]["READ_MAILS"]["SESSION_STORE_PASSWD"] + r = redis.Redis( + host="localhost", + port=6379, + decode_responses=True, + protocol=3, + username="jwebmail", + password=passwd, + ) + passwd = r.getex(f"jwm:user:{username}", EXPIRATION_SEC) + if passwd is None: return None + return JWebmailUser(username, passwd) def get_read_mails_logged_in(): if "read_mails" in g: return g.read_mails - with shelve.open("user_sessions", flag="r") as s: - user_data = s[current_user.get_id()] + passwd = current_app.config["JWEBMAIL"]["READ_MAILS"]["SESSION_STORE_PASSWD"] + r = redis.Redis( + host="localhost", + port=6379, + decode_responses=True, + protocol=3, + username="jwebmail", + password=passwd, + ) + passwd = r.get(f"jwm:user:{current_user.get_id()}") + if passwd is None: + raise KeyError(current_user.get_id()) - qma = build_qma(current_user.get_id(), user_data.password) + qma = build_qma(current_user.get_id(), passwd) g.read_mails = qma return qma diff --git a/src/jwebmail/render_mail.py b/src/jwebmail/render_mail.py index 0e5406c..804b200 100644 --- a/src/jwebmail/render_mail.py +++ b/src/jwebmail/render_mail.py @@ -143,7 +143,9 @@ def mime_render(maintype, subtype, content, path): renderer = MIMERenderSubs.get((maintype, subtype)) or MIMERenderSubs.get(maintype) if not renderer: - return f'

Unsupported MIME type of {maintype}/{subtype}.

\n' + typ = f"{maintype}/{subtype}" + msg = gettext("Unsupported MIME type of {mime_type}.").format(mime_type=typ) + return f'

{msg}

\n' return renderer(subtype, content, path) diff --git a/src/jwebmail/templates/_main_table.html b/src/jwebmail/templates/_main_table.html index 02f9f81..b331087 100644 --- a/src/jwebmail/templates/_main_table.html +++ b/src/jwebmail/templates/_main_table.html @@ -18,7 +18,9 @@
diff --git a/src/jwebmail/templates/about.html b/src/jwebmail/templates/about.html index e197275..836b668 100644 --- a/src/jwebmail/templates/about.html +++ b/src/jwebmail/templates/about.html @@ -40,7 +40,11 @@ diff --git a/src/jwebmail/templates/login.html b/src/jwebmail/templates/login.html index 2915de3..d73238d 100644 --- a/src/jwebmail/templates/login.html +++ b/src/jwebmail/templates/login.html @@ -26,7 +26,7 @@
{% endif %} -
+ {{ login_form.csrf_token }} @@ -74,10 +74,10 @@ {% block scripts %} {% endblock %} diff --git a/src/jwebmail/translations/de/LC_MESSAGES/messages.po b/src/jwebmail/translations/de/LC_MESSAGES/messages.po index ad48d16..48b1dca 100644 --- a/src/jwebmail/translations/de/LC_MESSAGES/messages.po +++ b/src/jwebmail/translations/de/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-11-26 19:21+0100\n" +"POT-Creation-Date: 2023-12-04 22:59+0100\n" "PO-Revision-Date: 2023-11-23 12:18+0100\n" "Last-Translator: FULL NAME \n" "Language: de\n" @@ -18,150 +18,160 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.13.1\n" -#: jwebmail/render_mail.py:63 +#: src/jwebmail/render_mail.py:67 msgid "Attachment {filename} of type {filetype}" msgstr "Anhang {filename} des types {filetype}" -#: jwebmail/render_mail.py:113 +#: src/jwebmail/render_mail.py:117 msgid "From" msgstr "Von" -#: jwebmail/render_mail.py:114 +#: src/jwebmail/render_mail.py:118 msgid "To" -msgstr "Fuer" +msgstr "Für" -#: jwebmail/render_mail.py:115 +#: src/jwebmail/render_mail.py:119 msgid "CC" msgstr "" -#: jwebmail/render_mail.py:116 +#: src/jwebmail/render_mail.py:120 msgid "BCC" msgstr "" -#: jwebmail/webmail.py:39 +#: src/jwebmail/render_mail.py:147 +msgid "Unsupported MIME type of {mime_type}." +msgstr "Nicht unterstützter MIME-Typ {mime_type}." + +#: src/jwebmail/webmail.py:27 msgid "Username" msgstr "Nutzername" -#: jwebmail/webmail.py:41 +#: src/jwebmail/webmail.py:29 msgid "Password" msgstr "Passwort" -#: jwebmail/webmail.py:75 -msgid "login failed" -msgstr "Login fehlgeschlagen" +#: src/jwebmail/webmail.py:64 +msgid "login failed!" +msgstr "Login fehlgeschlagen!" -#: jwebmail/webmail.py:120 +#: src/jwebmail/webmail.py:111 msgid "displaying {start} - {end} of {total} {record_name}" msgstr "zeige {start} - {end} von {total} {record_name} an" -#: jwebmail/webmail.py:173 +#: src/jwebmail/webmail.py:164 msgid "succ_move" msgstr "erfolgreich Verschoben" -#: jwebmail/webmail.py:221 +#: src/jwebmail/webmail.py:211 msgid "error_send" msgstr "Fehler beim senden" -#: jwebmail/webmail.py:223 +#: src/jwebmail/webmail.py:213 msgid "succ_send" msgstr "erfolgreich Verschoben" -#: jwebmail/templates/_bot_nav.html:11 +#: src/jwebmail/templates/_bot_nav.html:11 msgid "Move to" msgstr "Verschiebe nach" -#: jwebmail/templates/_bot_nav.html:19 jwebmail/templates/_folders.html:21 +#: src/jwebmail/templates/_bot_nav.html:19 +#: src/jwebmail/templates/_folders.html:21 msgid "Home" msgstr "Ursprung" -#: jwebmail/templates/_bot_nav.html:26 +#: src/jwebmail/templates/_bot_nav.html:26 msgid "Move" msgstr "Verschieben" -#: jwebmail/templates/_bot_nav.html:38 +#: src/jwebmail/templates/_bot_nav.html:38 msgid "Remove" -msgstr "Loeschen" +msgstr "Löschen" -#: jwebmail/templates/_bot_nav.html:44 +#: src/jwebmail/templates/_bot_nav.html:44 msgid "check all" msgstr "alle markieren" -#: jwebmail/templates/_folders.html:36 +#: src/jwebmail/templates/_folders.html:36 #, python-format msgid "%(total_new_mails)s new" msgstr "%(total_new_mails)s neu" -#: jwebmail/templates/_folders.html:41 +#: src/jwebmail/templates/_folders.html:41 msgid "mailbox size: " -msgstr "mailbox groesse: " +msgstr "mailbox größe: " -#: jwebmail/templates/_top_nav.html:4 +#: src/jwebmail/templates/_top_nav.html:4 msgid "Logout" msgstr "Abmelden" -#: jwebmail/templates/_top_nav.html:5 +#: src/jwebmail/templates/_top_nav.html:5 msgid "Write" msgstr "Schreiben" -#: jwebmail/templates/_top_nav.html:11 +#: src/jwebmail/templates/_top_nav.html:11 msgid "Search" msgstr "Suchen" -#: jwebmail/templates/_top_nav.html:26 +#: src/jwebmail/templates/_top_nav.html:26 msgid "Sort" msgstr "Sortieren" -#: jwebmail/templates/_top_nav.html:32 jwebmail/templates/_top_nav.html:33 +#: src/jwebmail/templates/_top_nav.html:32 +#: src/jwebmail/templates/_top_nav.html:33 msgid "Date" msgstr "Datum" -#: jwebmail/templates/_top_nav.html:32 jwebmail/templates/_top_nav.html:34 -#: jwebmail/templates/_top_nav.html:35 +#: src/jwebmail/templates/_top_nav.html:32 +#: src/jwebmail/templates/_top_nav.html:34 +#: src/jwebmail/templates/_top_nav.html:35 msgid "Descending" msgstr "Absteigend" -#: jwebmail/templates/_top_nav.html:33 jwebmail/templates/_top_nav.html:36 +#: src/jwebmail/templates/_top_nav.html:33 +#: src/jwebmail/templates/_top_nav.html:36 msgid "Ascending" msgstr "Aufsteigend" -#: jwebmail/templates/_top_nav.html:34 +#: src/jwebmail/templates/_top_nav.html:34 msgid "Size" -msgstr "Groesse" +msgstr "Größe" -#: jwebmail/templates/_top_nav.html:35 jwebmail/templates/_top_nav.html:36 +#: src/jwebmail/templates/_top_nav.html:35 +#: src/jwebmail/templates/_top_nav.html:36 msgid "Sender" msgstr "Sender" -#: jwebmail/templates/about.html:57 jwebmail/templates/login.html:13 -#: jwebmail/templates/login.html:69 +#: src/jwebmail/templates/about.html:61 src/jwebmail/templates/login.html:8 +#: src/jwebmail/templates/login.html:64 msgid "Login" msgstr "Anmelden" -#: jwebmail/templates/displayheaders.html:25 +#: src/jwebmail/templates/displayheaders.html:25 msgid "This folder is empty!" msgstr "Dieses Verzeichnis ist leer!" -#: jwebmail/templates/mainlayout.html:22 +#: src/jwebmail/templates/mainlayout.html:22 msgid "About" -msgstr "Ueber" +msgstr "Über" -#: jwebmail/templates/mainlayout.html:25 +#: src/jwebmail/templates/mainlayout.html:25 msgid "Version" msgstr "Version" -#: jwebmail/templates/not_found.html:19 +#: src/jwebmail/templates/not_found.html:19 msgid "start page" msgstr "Startseite" -#: jwebmail/templates/readmail.html:12 jwebmail/templates/writemail.html:78 +#: src/jwebmail/templates/readmail.html:12 +#: src/jwebmail/templates/writemail.html:78 msgid "back" -msgstr "zurueck" +msgstr "zurück" -#: jwebmail/templates/writemail.html:62 +#: src/jwebmail/templates/writemail.html:62 msgid "attach file" -msgstr "Datei anhaengen" +msgstr "Datei anhängen" -#: jwebmail/templates/writemail.html:71 +#: src/jwebmail/templates/writemail.html:71 msgid "Send" msgstr "Senden" diff --git a/src/jwebmail/webmail.py b/src/jwebmail/webmail.py index 4e47dbd..2cfc834 100644 --- a/src/jwebmail/webmail.py +++ b/src/jwebmail/webmail.py @@ -2,7 +2,7 @@ from urllib.parse import urlparse from flask import abort, current_app, flash, redirect, render_template, request, url_for from flask_babel import gettext, lazy_gettext -from flask_login import UserMixin, current_user, login_user, logout_user +from flask_login import current_user, login_user, logout_user from flask_paginate import Pagination, get_page_parameter, get_per_page_parameter from flask_wtf import FlaskForm from wtforms import ( @@ -16,15 +16,11 @@ from wtforms import ( ) from .model.read_mails import QMAuthError -from .read_mails import add_user, get_read_mails_logged_in +from .read_mails import JWebmailUser, add_user, get_read_mails_logged_in from .read_mails import login as rm_login from .render_mail import to_mime_type - -class JWebmailUser(UserMixin): - def __init__(self, mail_addr, password): - self.id = mail_addr - self.password = password +DEFAULT_LANGUAGE = "de" class LoginForm(FlaskForm): @@ -51,6 +47,7 @@ def login(): form = LoginForm() warn = "" + rc = 200 if form.validate_on_submit(): if rm_login(form.username.data, form.password.data): @@ -58,15 +55,17 @@ def login(): add_user(user) login_user(user) - next = request.args.get("next") + nxt = request.args.get("next") - if urlparse(next).netloc: + if urlparse(nxt).netloc: abort(401) - return redirect(next or url_for("displayheaders"), 303) + return redirect(nxt or url_for("displayheaders"), 303) else: - warn = gettext("login failed") + warn = gettext("login failed!") + elif request.method == "POST": + rc = 401 - return render_template("login.html", login_form=form, warn=warn), 401 + return render_template("login.html", login_form=form, warn=warn), rc def logout(): @@ -137,13 +136,13 @@ def displayheaders(folder=""): return render_template("displayheaders.html", **vals) -def readmail(msgid): +def readmail(msgid, folder=""): try: - mail = get_read_mails_logged_in().show("", msgid) + mail = get_read_mails_logged_in().show(folder, msgid) except QMAuthError: return render_template("not_found.html"), 404 - return render_template("readmail.html", msg=mail) + return render_template("readmail.html", msg=mail, folder=folder) def writemail(): @@ -166,10 +165,10 @@ def move(folder): return redirect(url_for("displayheaders"), 303) -def rawmail(msgid): +def rawmail(msgid, folder=""): path = request.args.get("path", "") - content = get_read_mails_logged_in().raw("", msgid, path) + content = get_read_mails_logged_in().raw(folder, msgid, path) headers = [] @@ -213,109 +212,3 @@ def sendmail(): flash(gettext("succ_send")) return redirect(url_for("displayheaders"), 303) - - -""" -sub remove { - my $self = shift; - - my $v = $self->validation; - $v->csrf_protect; - $v->required('mail'); - - if ($v->has_error) { - $self->reply->exception('errors in ' . join('', $v->failed->@*)); - return; - } - - my $auth = $self->stash(STS_AUTH); - - my $mm = $self->every_param('mail'); - my $folder = $self->stash('folder'); - - $self->users->remove($auth, $folder, $_) for @$mm; - - $self->res->code(303); - $self->redirect_to('displayheaders'); -} - - -### session password handling - -use constant { S_PASSWD => 'pw', S_OTP_S3D_PW => 'otp_s3d_pw' }; - -sub _rand_data { - my $len = shift; - - if (TRUE_RANDOM) { - #return makerandom_octet(Length => $len, Strength => 0); # was used for Crypt::Random - return urandom($len); - } - else { - my $res = ''; - for (0..$len-1) { - vec($res, $_, 8) = int rand 256; - } - - return $res; - } -} - -sub _session_passwd { - my ($self, $passwd, $challenge) = @_; - my $secAlg = LOGIN_SCHEME; - - $self->_warn_crypt; - - if (defined $passwd) { # set - if ($secAlg eq fc 'cram_md5') { - $self->session(S_PASSWD() => $passwd, challenge => $challenge); - } - elsif ($secAlg eq fc 'plain') { - unless ($passwd) { - $self->s3d(S_PASSWD, ''); - delete $self->session->{S_OTP_S3D_PW()}; - return; - } - die "'$passwd' contains invalid character \\n" if $passwd =~ /\n/; - if (length $passwd < 20) { - $passwd .= "\n" . ' ' x (20 - length($passwd) - 1); - } - my $passwd_utf8 = encode('UTF-8', $passwd); - my $rand_bytes = _rand_data(length $passwd_utf8); - $self->s3d(S_PASSWD, b64_encode($passwd_utf8 ^ $rand_bytes, '')); - $self->session(S_OTP_S3D_PW, b64_encode($rand_bytes, '')); - } - else { - die - } - } - else { # get - if ($secAlg eq fc 'cram_md5') { - wantarray or carp "you forgot the challenge"; - return ($self->session(S_PASSWD), $self->session('challenge')); - } - elsif ($secAlg eq fc 'plain') { - my $pw = b64_decode($self->s3d(S_PASSWD) || ''); - my $otp = b64_decode($self->session(S_OTP_S3D_PW) || ''); - my ($res) = split "\n", decode('UTF-8', $pw ^ $otp), 2; - return $res; - } - else { - die - } - } -} - -sub _warn_crypt { - my $self = shift; - - state $once = 0; - - if ( !TRUE_RANDOM && !$once && LOGIN_SCHEME eq fc 'plain' ) { - $self->log->warn("Falling back to pseudo random generation. Please install Crypt::URandom"); - $once = 1; - } -} - -""" -- cgit v1.2.3