diff options
31 files changed, 1044 insertions, 265 deletions
diff --git a/asset-sources/theme.scss b/asset-sources/theme.scss index 40aa55d..faa4f44 100644 --- a/asset-sources/theme.scss +++ b/asset-sources/theme.scss @@ -9,6 +9,10 @@ strong { font-weight: 700; } +.brand-link { + text-decoration: none; +} + .badge-container { width: 50px; height: 50px; diff --git a/doc/administration/installation.rst b/doc/administration/installation.rst index 53734b5..fd0def5 100644 --- a/doc/administration/installation.rst +++ b/doc/administration/installation.rst @@ -96,7 +96,7 @@ You can use the ``fietsctl`` command line program to add administrator users: .. code:: bash - .venv/bin/fietsctl -c production.ini useradd --admin + .venv/bin/fietsctl user add -c production.ini --admin Running Fietsboek ----------------- diff --git a/doc/index.rst b/doc/index.rst index 0b69ef1..73dce3f 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -13,6 +13,7 @@ Welcome to Fietsboek's documentation! administration developer user + man .. toctree:: :maxdepth: 1 diff --git a/doc/man.rst b/doc/man.rst new file mode 100644 index 0000000..03c7f78 --- /dev/null +++ b/doc/man.rst @@ -0,0 +1,15 @@ +Manpages +======== + +.. toctree:: + :maxdepth: 1 + + man/fietsctl + +In this section, you will find the manpages for the various commands that are +provided in Fietsboek. + +Each of those pages can also be turned into a "proper" manpage using +``rst2man``:: + + rst2man.py doc/man/fietsctl.rst fietsctl.1 diff --git a/doc/man/fietsctl.rst b/doc/man/fietsctl.rst new file mode 100644 index 0000000..67cbed4 --- /dev/null +++ b/doc/man/fietsctl.rst @@ -0,0 +1,260 @@ +fietsctl +======== + +Control utility for Fietsboek +----------------------------- +:Manual section: 1 + +SYNPOSIS +******** + +.. code-block:: text + + fietsctl maintenance-mode + fietsctl track {del|list} + fietsctl user {add|del|hittekaart|list|modify|passwd} + fietsctl version + +DESCRIPTION +*********** + +The ``fietsctl`` script is provided for administrative purposes. It allows you +to manage the state and content of a Fietsboek instance from the command line. + +.. warning:: + + The ``fietsctl`` script does not do any access checking and does not + require and logins or passwords. You must use the permissions of your + system and database server to restrict the access to ``fietsctl`` by + restricting access to the data stores directly. + +Detailed versions of the commands are described below. + +Note that most commands expect the path to the configuration file to be given +(e.g. ``-c production.ini``). The default uses ``fietsboek.ini``. This can be +overridden using the ``-c``/``--config`` option. + +.. note:: + + All commands support the ``--help`` option, which will give you a quick + overview of how the command works and which options are available. + +USER MANAGEMENT +*************** + +You can use the ``fietsctl user`` subcommand to manage the users in the +Fietsboek system. + +Many functions which deal with existing users (delete, modify, ...) allow the +user to be specified either by their ID using the ``-i``/``--id`` option, or by +their email address using ``-e``/``--email``. You can obtain the IDs of users +using the ``fietsctl user list`` command. + +ADDING A USER +############# + +.. code-block:: text + + fietsctl user add [-c CONFIG] [--email EMAIL] [--password PASSWORD] [--admin] + +This command adds a user to the system. It can be called with no arguments, in +which case all required values are prompted for. + +If the new user should be made an admin, use the ``--admin`` flag. If not +given, the user will *not* be made an admin. In any case, the user is +automatically verified. If you want to change the admin or verification status +after creating a user, use the ``fietsctl user modify`` command (see below). + +It is advised that you do not supply the password on the command line to +prevent it from appearing in the command history. Either disable the history, +or leave out the password (in which case you will be prompted). + +Note that this function does not check the ``enable_account_registration`` +setting, so it can always be used to add new users to the system. + +Note further that this function does less checks then the web interface (e.g. +it does not require an email verification), so be careful which values you +enter. + +REMOVING A USER +############### + +.. code-block:: text + + fietsctl user del [-c CONFIG] [-f/--force] [-i/--id ID] [-e/--email EMAIL] + +Removes a user from the system. This will remove the user account and all +associated data (the user's tracks, comments, ...). + +By default, the command will ask for confirmation. You can specify the +``-f``/``--force`` flag to override this check. + +GENERATING HEATMAPS +################### + +.. code-block:: text + + fietsctl user hittekaart [-c CONFIG] [-i/--id ID] [-e/--email EMAIL] [--delete] [--mode heatmap|tilehunt] + +With ``fiettsctl user hittekaart`` you can force a hittekaart run for a +specific user. By default, only the heatmap is generated, but you can use +``--mode`` to select which overlay map you want to generate. You can also +specify ``--mode`` multiple times to generate multiple heat maps with a single +invocation. + +If you want to delete a heatmap, use the ``--delete`` option. It also respects +the ``--mode`` selection, so you can delete individual types of heatmaps. + +LISTING USERS +############# + +.. code-block:: text + + fietsctl user list [-c CONFIG] + +Outputs a list of all users in the system, one user per line. Each line +consists of: + +.. code-block:: text + + [av] ID - EMAIL - NAME + +The "a" indicates that the user has admin permissions. If the user has no admin +permissions, a "-" is shown instead. + +The "v" indicates that the user has their email address verified. If the user +has not verified their email address, a "-" is shown instead. + +MODIFYING USERS +############### + +.. code-block:: text + + fietsctl user modify [-c CONFIG] [-i/--id ID] [-e/--email EMAIL] [--admin/--no-admin] [--verified/--no-verified] + +Modifies a user. This can currently be used to set the admin and verification +status of a user. If you want to change the password, use ``fietsctl user +passwd`` (see below). You cannot currently change the email address or name of +a user with this command (note that the ``--email`` option is for user +*selection*, not *modification*). + +If you do not specifiy either ``--admin`` or ``--no-admin``, then the current +value of the user is not changed. The same goes for ``--verified`` and +``--no-verified``, if neither is given, the current value is not changed. + +CHANGING USER PASSWORDS +####################### + +.. code-block:: text + + fietsctl user passwd [-c CONFIG] [-i/--id ID] [-e/--email EMAIL] [--password PASSWORD] + +Changes the password of the specified user. If the password is not given via +``--password``, then the password is interactively prompted for. Be careful +when using ``--password`` as sensitive information might end up in the shell +history! + +Note that this function does fewer checks than the web interface, as such it is +possible to set passwords that users cannot set themselves (e.g. very short +ones). + +TRACK MANAGEMENT +**************** + +The ``fietsctl track`` subcommand can be used to manage the tracks. + +LISTING TRACKS +############## + +.. code-block:: text + + fietsctl track list [-c CONFIG] + +Lists all tracks in the system. This outputs one line per track, plus final +summary information. + +For each track, the following information is shown: + +* The track's ID +* The size of the track's data (this includes the size of the data directory, + but not the size of the database elements) +* The track's owner (both name and email address) +* The track's title. + +REMOVING A TRACK +################ + +.. code-block:: text + + fietsctl track del [-c CONFIG] -i/--id ID [-f/--force] + +Deletes the specified track. The right ID can be found via the ``track list`` +command, or via the web interface. + +This command deletes the track including its pictures and comments. + +By default, the command will ask for confirmation. You can specify the +``-f``/``--force`` flag to override this check. + +MAINTENANCE MODE +**************** + +The ``fietsctl maintenance-mode`` subcommand manages the maintenance mode. + +ACTIVATING MAINTENANCE +###################### + +.. code-block:: text + + fietsctl maintenance-mode [-c CONFIG] REASON + +Enables the maintenance mode with the given reason. The reason should be a +short string that describes why Fietsboek is disabled. It will be shown to the +users on the error page. + +CHECKING MAINTENANCE +#################### + +.. code-block:: text + + fietsctl maintenance-mode [-c CONFIG] + +Without the reason, the command will output the current status of the +maintenance mode. + +DEACTIVATING MAINTENANCE +######################## + +.. code-block:: text + + fietsctl maintenance-mode [-c CONFIG] --disable + +With the ``--disable`` option, the maintenance mode will be disabled. + +BUGS +**** + +Bugs can be reported either via the issue tracker +(https://gitlab.com/dunj3/fietsboek/-/issues) or via email (fietsboek at +kingdread dot de). + +AUTHOR +****** + +This program is part of Fietsboek, written by the Fietsboek authors. + +COPYRIGHT +********* + +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 <https://www.gnu.org/licenses/>. diff --git a/fietsboek/actions.py b/fietsboek/actions.py index 6afbfa4..395843f 100644 --- a/fietsboek/actions.py +++ b/fietsboek/actions.py @@ -12,15 +12,17 @@ from typing import List, Optional import brotli import gpxpy +from pyramid.i18n import TranslationString as _ from pyramid.request import Request from sqlalchemy import select from sqlalchemy.orm.session import Session -from . import models +from . import email, models from . import transformers as mod_transformers from . import util from .data import DataManager, TrackDataDir from .models.track import TrackType, Visibility +from .models.user import TokenType LOGGER = logging.getLogger(__name__) @@ -208,7 +210,7 @@ def execute_transformers(request: Request, track: models.Track) -> Optional[gpxp transformer.execute(gpx) LOGGER.debug("Saving transformed file for %d", track.id) - manager.compress_gpx(gpx.to_xml().encode("utf-8")) + manager.compress_gpx(util.encode_gpx(gpx)) LOGGER.debug("Saving new transformers on %d", track.id) track.transformers = serialized @@ -219,3 +221,42 @@ def execute_transformers(request: Request, track: models.Track) -> Optional[gpxp track.ensure_cache(gpx) request.dbsession.add(track.cache) return gpx + + +def send_verification_token(request: Request, user: models.User): + """Creates a verification token and sends it to the user. + + If no verification token exists yet, a fresh one is created. + + If a token already exists, a fresh one is still created. The old token + stays valid until its expiry date. + + Note that this function does not provide the user with feedback other than + the email. You may want to show a flash message or similar to show that + something has happened. + + :param request: The request. + :param user: The user who to generate a verification token for. + """ + # Some of this code appears in the password reset form as well, but that's + # fine for now. + # pylint: disable=duplicate-code + token = models.Token.generate(user, TokenType.VERIFY_EMAIL) + request.dbsession.add(token) + + message = email.prepare_message( + request.config.email_from, + user.email, + request.localizer.translate(_("email.verify_mail.subject")), + ) + message.set_content( + request.localizer.translate(_("email.verify.text")).format( + request.route_url("use-token", uuid=token.uuid) + ) + ) + email.send_message( + request.config.email_smtp_url, + request.config.email_username, + request.config.email_password.get_secret_value(), + message, + ) diff --git a/fietsboek/data.py b/fietsboek/data.py index 7457986..9e0d45d 100644 --- a/fietsboek/data.py +++ b/fietsboek/data.py @@ -5,6 +5,7 @@ the database itself. This module makes access to such data objects easier. """ import datetime import logging +import os import random import shutil import string @@ -16,7 +17,7 @@ import brotli import gpxpy from filelock import FileLock -from .util import secure_filename +from . import util LOGGER = logging.getLogger(__name__) @@ -31,7 +32,7 @@ def generate_filename(filename: Optional[str]) -> str: :return: The generated filename. """ if filename: - good_name = secure_filename(filename) + good_name = util.secure_filename(filename) if good_name: random_prefix = "".join(random.choice(string.ascii_lowercase) for _ in range(5)) return f"{random_prefix}_{good_name}" @@ -242,6 +243,16 @@ class TrackDataDir: shutil.move(self.path, new_name) self.journal.append(("purge", new_name)) + def size(self) -> int: + """Returns the size of the data that this track entails. + + :return: The size of bytes that this track consumes. + """ + size = 0 + for root, _, files in os.walk(self.path): + size += sum(os.path.getsize(os.path.join(root, fname)) for fname in files) + return size + def gpx_path(self) -> Path: """Returns the path of the GPX file. @@ -313,7 +324,7 @@ class TrackDataDir: gpx.description = description gpx.time = time - self.compress_gpx(gpx.to_xml().encode("utf-8")) + self.compress_gpx(util.encode_gpx(gpx)) def backup(self): """Create a backup of the GPX file.""" @@ -345,7 +356,7 @@ class TrackDataDir: :param image_id: ID of the image. :return: A path pointing to the requested image. """ - image = self.path / "images" / secure_filename(image_id) + image = self.path / "images" / util.secure_filename(image_id) if not image.exists(): raise FileNotFoundError("The requested image does not exist") return image @@ -377,7 +388,7 @@ class TrackDataDir: :param image_id: ID of the image. """ # Be sure to not delete anything else than the image file - image_id = secure_filename(image_id) + image_id = util.secure_filename(image_id) if "/" in image_id or "\\" in image_id: return path = self.image_path(image_id) diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.mo b/fietsboek/locale/de/LC_MESSAGES/messages.mo Binary files differindex 887f9bc..10f468c 100644 --- a/fietsboek/locale/de/LC_MESSAGES/messages.mo +++ b/fietsboek/locale/de/LC_MESSAGES/messages.mo diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.po b/fietsboek/locale/de/LC_MESSAGES/messages.po index 5b6c654..cb62dbb 100644 --- a/fietsboek/locale/de/LC_MESSAGES/messages.po +++ b/fietsboek/locale/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-05-15 20:20+0200\n" +"POT-Creation-Date: 2023-05-31 20:46+0200\n" "PO-Revision-Date: 2022-07-02 17:35+0200\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language: de\n" @@ -18,11 +18,22 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.12.1\n" -#: fietsboek/util.py:280 +#: fietsboek/actions.py:250 +msgid "email.verify_mail.subject" +msgstr "Fietsboek Konto Bestätigung" + +#: fietsboek/actions.py:253 +msgid "email.verify.text" +msgstr "" +"Um Dein Fietsboek-Konto zu bestätigen, nutze diesen Link: {}\n" +"\n" +"Falls Du kein Konto angelegt hast, ignoriere diese E-Mail." + +#: fietsboek/util.py:304 msgid "password_constraint.mismatch" msgstr "Passwörter stimmen nicht überein" -#: fietsboek/util.py:282 +#: fietsboek/util.py:306 msgid "password_constraint.length" msgstr "Passwort zu kurz" @@ -478,72 +489,72 @@ msgstr "Absenden" msgid "page.upload.form.cancel" msgstr "Abbrechen" -#: fietsboek/templates/home.jinja2:5 +#: fietsboek/templates/home.jinja2:6 msgid "page.home.title" msgstr "Startseite" -#: fietsboek/templates/home.jinja2:8 +#: fietsboek/templates/home.jinja2:17 msgid "page.home.unfinished_uploads" msgstr "" "Es sind noch nicht abgeschlossene Uploads vorhanden. Klicke auf die " "Links, um sie fortzusetzen:" -#: fietsboek/templates/home.jinja2:27 fietsboek/templates/home.jinja2:34 -#: fietsboek/templates/home.jinja2:52 +#: fietsboek/templates/home.jinja2:31 fietsboek/templates/home.jinja2:38 +#: fietsboek/templates/home.jinja2:56 msgid "page.home.summary.track" msgid_plural "page.home.summary.tracks" msgstr[0] "%(num)d Strecke" msgstr[1] "%(num)d Strecken" -#: fietsboek/templates/home.jinja2:52 +#: fietsboek/templates/home.jinja2:56 msgid "page.home.total" msgstr "Gesamt" -#: fietsboek/templates/layout.jinja2:41 +#: fietsboek/templates/layout.jinja2:43 msgid "page.navbar.toggle" msgstr "Navigation umschalten" -#: fietsboek/templates/layout.jinja2:52 +#: fietsboek/templates/layout.jinja2:54 msgid "page.navbar.home" msgstr "Startseite" -#: fietsboek/templates/layout.jinja2:55 +#: fietsboek/templates/layout.jinja2:57 msgid "page.navbar.browse" msgstr "Stöbern" -#: fietsboek/templates/layout.jinja2:59 +#: fietsboek/templates/layout.jinja2:61 msgid "page.navbar.upload" msgstr "Hochladen" -#: fietsboek/templates/layout.jinja2:68 +#: fietsboek/templates/layout.jinja2:70 msgid "page.navbar.user" msgstr "Nutzer" -#: fietsboek/templates/layout.jinja2:72 +#: fietsboek/templates/layout.jinja2:74 msgid "page.navbar.welcome_user" msgstr "Willkommen, {}!" -#: fietsboek/templates/layout.jinja2:75 +#: fietsboek/templates/layout.jinja2:77 msgid "page.navbar.logout" msgstr "Abmelden" -#: fietsboek/templates/layout.jinja2:78 +#: fietsboek/templates/layout.jinja2:80 msgid "page.navbar.profile" msgstr "Profil" -#: fietsboek/templates/layout.jinja2:81 +#: fietsboek/templates/layout.jinja2:83 msgid "page.navbar.user_data" msgstr "Persönliche Daten" -#: fietsboek/templates/layout.jinja2:85 +#: fietsboek/templates/layout.jinja2:87 msgid "page.navbar.admin" msgstr "Admin" -#: fietsboek/templates/layout.jinja2:91 +#: fietsboek/templates/layout.jinja2:93 msgid "page.navbar.login" msgstr "Anmelden" -#: fietsboek/templates/layout.jinja2:95 +#: fietsboek/templates/layout.jinja2:97 msgid "page.navbar.create_account" msgstr "Konto Erstellen" @@ -571,6 +582,10 @@ msgstr "Anmelden" msgid "page.login.forgot_password" msgstr "Passwort vergessen" +#: fietsboek/templates/login.jinja2:45 +msgid "page.login.resend_verification" +msgstr "Bestätigungsmail erneut senden" + #: fietsboek/templates/password_reset.jinja2:5 msgid "page.password_reset.title" msgstr "Passwort Zurücksetzen" @@ -669,6 +684,23 @@ msgstr "E-Mail-Adresse" msgid "page.request_password.request" msgstr "Anfrage senden" +#: fietsboek/templates/resend_verification.jinja2:5 +msgid "page.resend_verification.title" +msgstr "Bestätigungsmail Erneut Senden" + +#: fietsboek/templates/resend_verification.jinja2:6 +msgid "page.resend_verification.info" +msgstr "" +"Hier kannst Du eine neue E-Mail zur Bestätigung Deines Kontos anfordern." + +#: fietsboek/templates/resend_verification.jinja2:12 +msgid "page.resend_verification.email" +msgstr "E-Mail-Adresse" + +#: fietsboek/templates/resend_verification.jinja2:17 +msgid "page.resend_verification.request" +msgstr "E-Mail anfordern" + #: fietsboek/templates/upload.jinja2:9 msgid "page.upload.form.gpx" msgstr "GPX Datei" @@ -753,26 +785,15 @@ msgstr "" "Diese Transformation passt die Höhenangabe für Punkte an, bei denen die " "Höhe sprunghaft steigt oder fällt." -#: fietsboek/views/account.py:54 +#: fietsboek/views/account.py:53 msgid "flash.invalid_name" msgstr "Ungültiger Name" -#: fietsboek/views/account.py:59 +#: fietsboek/views/account.py:58 msgid "flash.invalid_email" msgstr "Ungültige E-Mail-Adresse" -#: fietsboek/views/account.py:72 -msgid "email.verify_mail.subject" -msgstr "Fietsboek Konto Bestätigung" - -#: fietsboek/views/account.py:75 -msgid "email.verify.text" -msgstr "" -"Um Dein Fietsboek-Konto zu bestätigen, nutze diesen Link: {}\n" -"\n" -"Falls Du kein Konto angelegt hast, ignoriere diese E-Mail." - -#: fietsboek/views/account.py:86 +#: fietsboek/views/account.py:67 msgid "flash.a_confirmation_link_has_been_sent" msgstr "Ein Bestätigungslink wurde versandt" @@ -788,35 +809,35 @@ msgstr "Wappen bearbeitet" msgid "flash.badge_deleted" msgstr "Wappen gelöscht" -#: fietsboek/views/default.py:121 +#: fietsboek/views/default.py:115 msgid "flash.invalid_credentials" msgstr "Ungültige Nutzerdaten" -#: fietsboek/views/default.py:125 +#: fietsboek/views/default.py:119 msgid "flash.account_not_verified" msgstr "Konto noch nicht bestätigt" -#: fietsboek/views/default.py:128 +#: fietsboek/views/default.py:122 msgid "flash.logged_in" msgstr "Du bist nun angemeldet" -#: fietsboek/views/default.py:150 +#: fietsboek/views/default.py:142 msgid "flash.logged_out" msgstr "Du bist nun abgemeldet" -#: fietsboek/views/default.py:184 +#: fietsboek/views/default.py:172 msgid "flash.reset_invalid_email" msgstr "Ungültige E-Mail-Adresse angegeben" -#: fietsboek/views/default.py:189 +#: fietsboek/views/default.py:177 msgid "flash.password_token_generated" msgstr "Ein Link zum Zurücksetzen des Passworts wurde versandt" -#: fietsboek/views/default.py:194 +#: fietsboek/views/default.py:182 msgid "page.password_reset.email.subject" msgstr "Fietsboek Passwortzurücksetzung" -#: fietsboek/views/default.py:197 +#: fietsboek/views/default.py:185 msgid "page.password_reset.email.body" msgstr "" "Du kannst Dein Fietsboek-Passwort hier zurücksetzen: {}\n" @@ -824,15 +845,27 @@ msgstr "" "Falls Du keine Passwortzurücksetzung beantragt hast, dann ignoriere diese" " E-Mail." -#: fietsboek/views/default.py:230 +#: fietsboek/views/default.py:224 +msgid "flash.resend_verification_email_fail" +msgstr "Ungültige E-Mail-Adresse angegeben" + +#: fietsboek/views/default.py:229 +msgid "flash.verification_token_generated" +msgstr "Ein Link zur Bestätigung Deines Kontos wurde versandt" + +#: fietsboek/views/default.py:249 +msgid "flash.token_expired" +msgstr "Der Link ist nicht mehr gültig" + +#: fietsboek/views/default.py:255 msgid "flash.email_verified" msgstr "E-Mail-Adresse bestätigt" -#: fietsboek/views/default.py:244 +#: fietsboek/views/default.py:269 msgid "flash.password_updated" msgstr "Passwort aktualisiert" -#: fietsboek/views/detail.py:140 +#: fietsboek/views/detail.py:155 msgid "flash.track_deleted" msgstr "Strecke gelöscht" diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.mo b/fietsboek/locale/en/LC_MESSAGES/messages.mo Binary files differindex bfd051f..df35686 100644 --- a/fietsboek/locale/en/LC_MESSAGES/messages.mo +++ b/fietsboek/locale/en/LC_MESSAGES/messages.mo diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.po b/fietsboek/locale/en/LC_MESSAGES/messages.po index d1a8e8d..1cac820 100644 --- a/fietsboek/locale/en/LC_MESSAGES/messages.po +++ b/fietsboek/locale/en/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-05-15 20:20+0200\n" +"POT-Creation-Date: 2023-05-31 20:46+0200\n" "PO-Revision-Date: 2023-04-03 20:42+0200\n" "Last-Translator: \n" "Language: en\n" @@ -18,11 +18,22 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.12.1\n" -#: fietsboek/util.py:280 +#: fietsboek/actions.py:250 +msgid "email.verify_mail.subject" +msgstr "Fietsboek Account Verification" + +#: fietsboek/actions.py:253 +msgid "email.verify.text" +msgstr "" +"To verify your Fietsboek account, please use this link: {}\n" +"\n" +"If you did not create an account, ignore this email." + +#: fietsboek/util.py:304 msgid "password_constraint.mismatch" msgstr "Passwords don't match" -#: fietsboek/util.py:282 +#: fietsboek/util.py:306 msgid "password_constraint.length" msgstr "Password not long enough" @@ -474,70 +485,70 @@ msgstr "Upload" msgid "page.upload.form.cancel" msgstr "Cancel" -#: fietsboek/templates/home.jinja2:5 +#: fietsboek/templates/home.jinja2:6 msgid "page.home.title" msgstr "Home" -#: fietsboek/templates/home.jinja2:8 +#: fietsboek/templates/home.jinja2:17 msgid "page.home.unfinished_uploads" msgstr "You have unfinished uploads. Click on the links below to resume them:" -#: fietsboek/templates/home.jinja2:27 fietsboek/templates/home.jinja2:34 -#: fietsboek/templates/home.jinja2:52 +#: fietsboek/templates/home.jinja2:31 fietsboek/templates/home.jinja2:38 +#: fietsboek/templates/home.jinja2:56 msgid "page.home.summary.track" msgid_plural "page.home.summary.tracks" msgstr[0] "%(num)d track" msgstr[1] "%(num)d tracks" -#: fietsboek/templates/home.jinja2:52 +#: fietsboek/templates/home.jinja2:56 msgid "page.home.total" msgstr "Total" -#: fietsboek/templates/layout.jinja2:41 +#: fietsboek/templates/layout.jinja2:43 msgid "page.navbar.toggle" msgstr "Toggle navigation" -#: fietsboek/templates/layout.jinja2:52 +#: fietsboek/templates/layout.jinja2:54 msgid "page.navbar.home" msgstr "Home" -#: fietsboek/templates/layout.jinja2:55 +#: fietsboek/templates/layout.jinja2:57 msgid "page.navbar.browse" msgstr "Browse" -#: fietsboek/templates/layout.jinja2:59 +#: fietsboek/templates/layout.jinja2:61 msgid "page.navbar.upload" msgstr "Upload" -#: fietsboek/templates/layout.jinja2:68 +#: fietsboek/templates/layout.jinja2:70 msgid "page.navbar.user" msgstr "User" -#: fietsboek/templates/layout.jinja2:72 +#: fietsboek/templates/layout.jinja2:74 msgid "page.navbar.welcome_user" msgstr "Welcome, {}!" -#: fietsboek/templates/layout.jinja2:75 +#: fietsboek/templates/layout.jinja2:77 msgid "page.navbar.logout" msgstr "Logout" -#: fietsboek/templates/layout.jinja2:78 +#: fietsboek/templates/layout.jinja2:80 msgid "page.navbar.profile" msgstr "Profile" -#: fietsboek/templates/layout.jinja2:81 +#: fietsboek/templates/layout.jinja2:83 msgid "page.navbar.user_data" msgstr "Personal Data" -#: fietsboek/templates/layout.jinja2:85 +#: fietsboek/templates/layout.jinja2:87 msgid "page.navbar.admin" msgstr "Admin" -#: fietsboek/templates/layout.jinja2:91 +#: fietsboek/templates/layout.jinja2:93 msgid "page.navbar.login" msgstr "Login" -#: fietsboek/templates/layout.jinja2:95 +#: fietsboek/templates/layout.jinja2:97 msgid "page.navbar.create_account" msgstr "Create Account" @@ -565,6 +576,10 @@ msgstr "Login" msgid "page.login.forgot_password" msgstr "Forgot password" +#: fietsboek/templates/login.jinja2:45 +msgid "page.login.resend_verification" +msgstr "Re-send verification mail" + #: fietsboek/templates/password_reset.jinja2:5 msgid "page.password_reset.title" msgstr "Reset Your Password" @@ -663,6 +678,22 @@ msgstr "Email" msgid "page.request_password.request" msgstr "Send request" +#: fietsboek/templates/resend_verification.jinja2:5 +msgid "page.resend_verification.title" +msgstr "Re-send Verification Mail" + +#: fietsboek/templates/resend_verification.jinja2:6 +msgid "page.resend_verification.info" +msgstr "Here you can request a new mail to verify your account" + +#: fietsboek/templates/resend_verification.jinja2:12 +msgid "page.resend_verification.email" +msgstr "Email" + +#: fietsboek/templates/resend_verification.jinja2:17 +msgid "page.resend_verification.request" +msgstr "Send request" + #: fietsboek/templates/upload.jinja2:9 msgid "page.upload.form.gpx" msgstr "GPX file" @@ -743,26 +774,15 @@ msgstr "Fix elevation jumps" msgid "transformers.fix-elevation-jumps.description" msgstr "This transformer fixes abrupt jumps in the elevation value." -#: fietsboek/views/account.py:54 +#: fietsboek/views/account.py:53 msgid "flash.invalid_name" msgstr "Invalid name" -#: fietsboek/views/account.py:59 +#: fietsboek/views/account.py:58 msgid "flash.invalid_email" msgstr "Invalid email" -#: fietsboek/views/account.py:72 -msgid "email.verify_mail.subject" -msgstr "Fietsboek Account Verification" - -#: fietsboek/views/account.py:75 -msgid "email.verify.text" -msgstr "" -"To verify your Fietsboek account, please use this link: {}\n" -"\n" -"If you did not create an account, ignore this email." - -#: fietsboek/views/account.py:86 +#: fietsboek/views/account.py:67 msgid "flash.a_confirmation_link_has_been_sent" msgstr "A confirmation link has been sent" @@ -778,50 +798,62 @@ msgstr "Badge has been modified" msgid "flash.badge_deleted" msgstr "Badge has been deleted" -#: fietsboek/views/default.py:121 +#: fietsboek/views/default.py:115 msgid "flash.invalid_credentials" msgstr "Invalid login credentials" -#: fietsboek/views/default.py:125 +#: fietsboek/views/default.py:119 msgid "flash.account_not_verified" msgstr "Your account is not verified yet" -#: fietsboek/views/default.py:128 +#: fietsboek/views/default.py:122 msgid "flash.logged_in" msgstr "You are now logged in" -#: fietsboek/views/default.py:150 +#: fietsboek/views/default.py:142 msgid "flash.logged_out" msgstr "You have been logged out" -#: fietsboek/views/default.py:184 +#: fietsboek/views/default.py:172 msgid "flash.reset_invalid_email" msgstr "Invalid email address provided" -#: fietsboek/views/default.py:189 +#: fietsboek/views/default.py:177 msgid "flash.password_token_generated" msgstr "A password reset email has been sent" -#: fietsboek/views/default.py:194 +#: fietsboek/views/default.py:182 msgid "page.password_reset.email.subject" msgstr "Fietsboek Password Reset" -#: fietsboek/views/default.py:197 +#: fietsboek/views/default.py:185 msgid "page.password_reset.email.body" msgstr "" "You can reset your Fietsboek password here: {}\n" "\n" "If you did not request a password reset, ignore this email." -#: fietsboek/views/default.py:230 +#: fietsboek/views/default.py:224 +msgid "flash.resend_verification_email_fail" +msgstr "Invalid email address provided" + +#: fietsboek/views/default.py:229 +msgid "flash.verification_token_generated" +msgstr "A verification email has been sent" + +#: fietsboek/views/default.py:249 +msgid "flash.token_expired" +msgstr "The link has expired" + +#: fietsboek/views/default.py:255 msgid "flash.email_verified" msgstr "Your email address has been verified" -#: fietsboek/views/default.py:244 +#: fietsboek/views/default.py:269 msgid "flash.password_updated" msgstr "Password has been updated" -#: fietsboek/views/detail.py:140 +#: fietsboek/views/detail.py:155 msgid "flash.track_deleted" msgstr "Track has been deleted" diff --git a/fietsboek/locale/fietslog.pot b/fietsboek/locale/fietslog.pot index 7e3d440..8d43284 100644 --- a/fietsboek/locale/fietslog.pot +++ b/fietsboek/locale/fietslog.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-05-15 20:20+0200\n" +"POT-Creation-Date: 2023-05-31 20:46+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -17,11 +17,19 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.12.1\n" -#: fietsboek/util.py:280 +#: fietsboek/actions.py:250 +msgid "email.verify_mail.subject" +msgstr "" + +#: fietsboek/actions.py:253 +msgid "email.verify.text" +msgstr "" + +#: fietsboek/util.py:304 msgid "password_constraint.mismatch" msgstr "" -#: fietsboek/util.py:282 +#: fietsboek/util.py:306 msgid "password_constraint.length" msgstr "" @@ -471,70 +479,70 @@ msgstr "" msgid "page.upload.form.cancel" msgstr "" -#: fietsboek/templates/home.jinja2:5 +#: fietsboek/templates/home.jinja2:6 msgid "page.home.title" msgstr "" -#: fietsboek/templates/home.jinja2:8 +#: fietsboek/templates/home.jinja2:17 msgid "page.home.unfinished_uploads" msgstr "" -#: fietsboek/templates/home.jinja2:27 fietsboek/templates/home.jinja2:34 -#: fietsboek/templates/home.jinja2:52 +#: fietsboek/templates/home.jinja2:31 fietsboek/templates/home.jinja2:38 +#: fietsboek/templates/home.jinja2:56 msgid "page.home.summary.track" msgid_plural "page.home.summary.tracks" msgstr[0] "" msgstr[1] "" -#: fietsboek/templates/home.jinja2:52 +#: fietsboek/templates/home.jinja2:56 msgid "page.home.total" msgstr "" -#: fietsboek/templates/layout.jinja2:41 +#: fietsboek/templates/layout.jinja2:43 msgid "page.navbar.toggle" msgstr "" -#: fietsboek/templates/layout.jinja2:52 +#: fietsboek/templates/layout.jinja2:54 msgid "page.navbar.home" msgstr "" -#: fietsboek/templates/layout.jinja2:55 +#: fietsboek/templates/layout.jinja2:57 msgid "page.navbar.browse" msgstr "" -#: fietsboek/templates/layout.jinja2:59 +#: fietsboek/templates/layout.jinja2:61 msgid "page.navbar.upload" msgstr "" -#: fietsboek/templates/layout.jinja2:68 +#: fietsboek/templates/layout.jinja2:70 msgid "page.navbar.user" msgstr "" -#: fietsboek/templates/layout.jinja2:72 +#: fietsboek/templates/layout.jinja2:74 msgid "page.navbar.welcome_user" msgstr "" -#: fietsboek/templates/layout.jinja2:75 +#: fietsboek/templates/layout.jinja2:77 msgid "page.navbar.logout" msgstr "" -#: fietsboek/templates/layout.jinja2:78 +#: fietsboek/templates/layout.jinja2:80 msgid "page.navbar.profile" msgstr "" -#: fietsboek/templates/layout.jinja2:81 +#: fietsboek/templates/layout.jinja2:83 msgid "page.navbar.user_data" msgstr "" -#: fietsboek/templates/layout.jinja2:85 +#: fietsboek/templates/layout.jinja2:87 msgid "page.navbar.admin" msgstr "" -#: fietsboek/templates/layout.jinja2:91 +#: fietsboek/templates/layout.jinja2:93 msgid "page.navbar.login" msgstr "" -#: fietsboek/templates/layout.jinja2:95 +#: fietsboek/templates/layout.jinja2:97 msgid "page.navbar.create_account" msgstr "" @@ -562,6 +570,10 @@ msgstr "" msgid "page.login.forgot_password" msgstr "" +#: fietsboek/templates/login.jinja2:45 +msgid "page.login.resend_verification" +msgstr "" + #: fietsboek/templates/password_reset.jinja2:5 msgid "page.password_reset.title" msgstr "" @@ -658,6 +670,22 @@ msgstr "" msgid "page.request_password.request" msgstr "" +#: fietsboek/templates/resend_verification.jinja2:5 +msgid "page.resend_verification.title" +msgstr "" + +#: fietsboek/templates/resend_verification.jinja2:6 +msgid "page.resend_verification.info" +msgstr "" + +#: fietsboek/templates/resend_verification.jinja2:12 +msgid "page.resend_verification.email" +msgstr "" + +#: fietsboek/templates/resend_verification.jinja2:17 +msgid "page.resend_verification.request" +msgstr "" + #: fietsboek/templates/upload.jinja2:9 msgid "page.upload.form.gpx" msgstr "" @@ -738,23 +766,15 @@ msgstr "" msgid "transformers.fix-elevation-jumps.description" msgstr "" -#: fietsboek/views/account.py:54 +#: fietsboek/views/account.py:53 msgid "flash.invalid_name" msgstr "" -#: fietsboek/views/account.py:59 +#: fietsboek/views/account.py:58 msgid "flash.invalid_email" msgstr "" -#: fietsboek/views/account.py:72 -msgid "email.verify_mail.subject" -msgstr "" - -#: fietsboek/views/account.py:75 -msgid "email.verify.text" -msgstr "" - -#: fietsboek/views/account.py:86 +#: fietsboek/views/account.py:67 msgid "flash.a_confirmation_link_has_been_sent" msgstr "" @@ -770,47 +790,59 @@ msgstr "" msgid "flash.badge_deleted" msgstr "" -#: fietsboek/views/default.py:121 +#: fietsboek/views/default.py:115 msgid "flash.invalid_credentials" msgstr "" -#: fietsboek/views/default.py:125 +#: fietsboek/views/default.py:119 msgid "flash.account_not_verified" msgstr "" -#: fietsboek/views/default.py:128 +#: fietsboek/views/default.py:122 msgid "flash.logged_in" msgstr "" -#: fietsboek/views/default.py:150 +#: fietsboek/views/default.py:142 msgid "flash.logged_out" msgstr "" -#: fietsboek/views/default.py:184 +#: fietsboek/views/default.py:172 msgid "flash.reset_invalid_email" msgstr "" -#: fietsboek/views/default.py:189 +#: fietsboek/views/default.py:177 msgid "flash.password_token_generated" msgstr "" -#: fietsboek/views/default.py:194 +#: fietsboek/views/default.py:182 msgid "page.password_reset.email.subject" msgstr "" -#: fietsboek/views/default.py:197 +#: fietsboek/views/default.py:185 msgid "page.password_reset.email.body" msgstr "" -#: fietsboek/views/default.py:230 +#: fietsboek/views/default.py:224 +msgid "flash.resend_verification_email_fail" +msgstr "" + +#: fietsboek/views/default.py:229 +msgid "flash.verification_token_generated" +msgstr "" + +#: fietsboek/views/default.py:249 +msgid "flash.token_expired" +msgstr "" + +#: fietsboek/views/default.py:255 msgid "flash.email_verified" msgstr "" -#: fietsboek/views/default.py:244 +#: fietsboek/views/default.py:269 msgid "flash.password_updated" msgstr "" -#: fietsboek/views/detail.py:140 +#: fietsboek/views/detail.py:155 msgid "flash.track_deleted" msgstr "" diff --git a/fietsboek/models/user.py b/fietsboek/models/user.py index e1b4841..4201d14 100644 --- a/fietsboek/models/user.py +++ b/fietsboek/models/user.py @@ -53,6 +53,9 @@ SCRYPT_PARAMETERS = { } SALT_LENGTH = 32 +TOKEN_LIFETIME = datetime.timedelta(hours=24) +"""Maximum validity time of a token.""" + friends_assoc = Table( "friends_assoc", @@ -457,5 +460,9 @@ class Token(Base): now = datetime.datetime.utcnow() return cls(user=user, uuid=token_uuid, date=now, token_type=token_type) + def age(self) -> datetime.timedelta: + """Returns the age of the token.""" + return abs(datetime.datetime.utcnow() - self.date) + Index("idx_token_uuid", Token.uuid, unique=True) diff --git a/fietsboek/routes.py b/fietsboek/routes.py index 8f109d9..60a566d 100644 --- a/fietsboek/routes.py +++ b/fietsboek/routes.py @@ -14,6 +14,7 @@ def includeme(config): config.add_route("track-archive", "/track/archive") config.add_route("password-reset", "/password-reset") + config.add_route("resend-verification", "/resend-verification") config.add_route("use-token", "/token/{uuid}") config.add_route("create-account", "/create-account") diff --git a/fietsboek/scripts/fietscron.py b/fietsboek/scripts/fietscron.py index 8116204..417b188 100644 --- a/fietsboek/scripts/fietscron.py +++ b/fietsboek/scripts/fietscron.py @@ -16,6 +16,7 @@ from .. import config as mod_config from .. import hittekaart, models from ..config import Config from ..data import DataManager +from ..models.user import TOKEN_LIFETIME from . import config_option LOGGER = logging.getLogger(__name__) @@ -48,6 +49,7 @@ def cli(config): LOGGER.debug("Starting maintenance tasks") remove_old_uploads(engine) + remove_old_tokens(engine) rebuild_cache(engine, data_manager) if config.hittekaart_autogenerate: @@ -65,6 +67,16 @@ def remove_old_uploads(engine: Engine): session.commit() +def remove_old_tokens(engine: Engine): + """Removes old tokens from the database.""" + LOGGER.info("Deleting old tokens") + limit = datetime.datetime.utcnow() - TOKEN_LIFETIME + session = Session(engine) + stmt = delete(models.Token).where(models.Token.date < limit) + session.execute(stmt) + session.commit() + + def rebuild_cache(engine: Engine, data_manager: DataManager): """Rebuilds the cache entries that are currently missing.""" LOGGER.debug("Rebuilding caches") diff --git a/fietsboek/scripts/fietsctl.py b/fietsboek/scripts/fietsctl.py index 4255b18..59462df 100644 --- a/fietsboek/scripts/fietsctl.py +++ b/fietsboek/scripts/fietsctl.py @@ -1,5 +1,6 @@ """Script to do maintenance work on a Fietsboek instance.""" # pylint: disable=too-many-arguments +import logging from typing import Optional import click @@ -8,13 +9,31 @@ from pyramid.paster import bootstrap, setup_logging from pyramid.scripting import AppEnvironment from sqlalchemy import select -from .. import __VERSION__, hittekaart, models +from .. import __VERSION__, hittekaart, models, util from ..data import DataManager from . import config_option +LOGGER = logging.getLogger("fietsctl") + EXIT_OKAY = 0 EXIT_FAILURE = 1 +FG_USER_NAME = "yellow" +FG_USER_EMAIL = "yellow" +FG_USER_TAG = "red" +FG_SIZE = "cyan" +FG_TRACK_TITLE = "green" +FG_TRACK_SIZE = "bright_red" + + +def human_bool(value: bool) -> str: + """Formats a boolean for CLI output. + + :param value: The value to format. + :return: The string representing the bool. + """ + return "yes" if value else "no" + def setup(config_path: str) -> AppEnvironment: """Sets up the logging and app environment for the scripts. @@ -38,10 +57,15 @@ def cli(): """CLI main entry point.""" -@cli.command("useradd") +@cli.group("user") +def cmd_user(): + """Management functions for user accounts.""" + + +@cmd_user.command("add") @config_option -@click.option("--email", help="email address of the user", prompt=True) -@click.option("--name", help="name of the user", prompt=True) +@click.option("--email", help="Email address of the user.", prompt=True) +@click.option("--name", help="Name of the user.", prompt=True) @click.option( "--password", help="password of the user", @@ -49,9 +73,16 @@ def cli(): hide_input=True, confirmation_prompt=True, ) -@click.option("--admin", help="make the new user an admin", is_flag=True) +@click.option("--admin", help="Make the new user an admin.", is_flag=True) @click.pass_context -def cmd_useradd(ctx: click.Context, config: str, email: str, name: str, password: str, admin: bool): +def cmd_user_add( + ctx: click.Context, + config: str, + email: str, + name: str, + password: str, + admin: bool, +): """Create a new user. This user creation bypasses the "enable_account_registration" setting. It @@ -89,14 +120,14 @@ def cmd_useradd(ctx: click.Context, config: str, email: str, name: str, password click.echo(user_id) -@cli.command("userdel") +@cmd_user.command("del") @config_option -@click.option("--force", "-f", help="override the safety check", is_flag=True) +@click.option("--force", "-f", help="Override the safety check.", is_flag=True) @optgroup.group("User selection", cls=RequiredMutuallyExclusiveOptionGroup) -@optgroup.option("--id", "-i", "id_", help="database ID of the user", type=int) -@optgroup.option("--email", "-e", help="email of the user") +@optgroup.option("--id", "-i", "id_", help="Database ID of the user.", type=int) +@optgroup.option("--email", "-e", help="Email address of the user.") @click.pass_context -def cmd_userdel( +def user_del( ctx: click.Context, config: str, force: bool, @@ -121,7 +152,7 @@ def cmd_userdel( if user is None: click.echo("Error: No such user found.", err=True) ctx.exit(EXIT_FAILURE) - click.echo(user.name) + click.secho(user.name, fg=FG_USER_NAME) click.echo(user.email) if not force: if not click.confirm("Really delete this user?"): @@ -131,9 +162,9 @@ def cmd_userdel( click.echo("User deleted") -@cli.command("userlist") +@cmd_user.command("list") @config_option -def cmd_userlist(config: str): +def cmd_user_list(config: str): """Prints a listing of all user accounts. The format is: @@ -153,17 +184,20 @@ def cmd_userlist(config: str): "a" if user.is_admin else "-", "v" if user.is_verified else "-", ) - click.echo(f"{tag} {user.id} - {user.email} - {user.name}") + tag = click.style(tag, fg=FG_USER_TAG) + user_email = click.style(user.email, fg=FG_USER_EMAIL) + user_name = click.style(user.name, fg=FG_USER_NAME) + click.echo(f"{tag} {user.id} - {user_email} - {user_name}") -@cli.command("passwd") +@cmd_user.command("passwd") @config_option -@click.option("--password", help="password of the user") +@click.option("--password", help="Password of the user.") @optgroup.group("User selection", cls=RequiredMutuallyExclusiveOptionGroup) -@optgroup.option("--id", "-i", "id_", help="database ID of the user", type=int) -@optgroup.option("--email", "-e", help="email of the user") +@optgroup.option("--id", "-i", "id_", help="Database ID of the user,", type=int) +@optgroup.option("--email", "-e", help="Email address of the user.") @click.pass_context -def cmd_passwd( +def cms_user_passwd( ctx: click.Context, config: str, password: Optional[str], @@ -186,56 +220,68 @@ def cmd_passwd( password = click.prompt("Password", hide_input=True, confirmation_prompt=True) user.set_password(password) - click.echo(f"Changed password of {user.name} ({user.email})") + user_name = click.style(user.name, fg=FG_USER_NAME) + user_email = click.style(user.email, fg=FG_USER_EMAIL) + click.echo(f"Changed password of {user_name} ({user_email})") -@cli.command("maintenance-mode") +@cmd_user.command("modify") @config_option -@click.option("--disable", help="disable the maintenance mode", is_flag=True) -@click.argument("reason", required=False) +@click.option("--admin/--no-admin", help="Make the user an admin.", default=None) +@click.option("--verified/--no-verified", help="Set the user verification status.", default=None) +@optgroup.group("User selection", cls=RequiredMutuallyExclusiveOptionGroup) +@optgroup.option("--id", "-i", "id_", help="Database ID of the user.", type=int) +@optgroup.option("--email", "-e", help="Email address of the user.") @click.pass_context -def cmd_maintenance_mode(ctx: click.Context, config: str, disable: bool, reason: Optional[str]): - """Check the status of the maintenance mode, or activate or deactive it. - - When REASON is given, enables the maintenance mode with the given text as - reason. - - With neither --disable nor REASON given, just checks the state of the - maintenance mode. - """ +def cms_user_modify( + ctx: click.Context, + config: str, + admin: Optional[bool], + verified: Optional[bool], + id_: Optional[int], + email: Optional[str], +): + """Modify a user.""" env = setup(config) - data_manager = env["request"].data_manager - if disable and reason: - click.echo("Cannot enable and disable maintenance mode at the same time", err=True) - ctx.exit(EXIT_FAILURE) - elif not disable and not reason: - maintenance = data_manager.maintenance_mode() - if maintenance is None: - click.echo("Maintenance mode is disabled") - else: - click.echo(f"Maintenance mode is enabled: {maintenance}") - elif disable: - (data_manager.data_dir / "MAINTENANCE").unlink() + if id_ is not None: + query = select(models.User).filter_by(id=id_) else: - (data_manager.data_dir / "MAINTENANCE").write_text(reason, encoding="utf-8") + query = models.User.query_by_email(email) + with env["request"].tm: + dbsession = env["request"].dbsession + user = dbsession.execute(query).scalar_one_or_none() + if user is None: + click.echo("Error: No such user found.", err=True) + ctx.exit(EXIT_FAILURE) + if admin is not None: + user.is_admin = admin + if verified is not None: + user.is_verified = verified -@cli.command("hittekaart") + user_name = click.style(user.name, fg=FG_USER_NAME) + user_email = click.style(user.email, fg=FG_USER_EMAIL) + click.echo(f"{user_name} - {user_email}") + click.echo(f"Is admin: {human_bool(user.is_admin)}") + click.echo(f"Is verified: {human_bool(user.is_verified)}") + + +@cmd_user.command("hittekaart") @config_option @click.option( "--mode", "modes", - help="Heatmap type to generate", + help="Heatmap type to generate.", type=click.Choice([mode.value for mode in hittekaart.Mode]), multiple=True, default=["heatmap"], ) -@click.option("--delete", help="Delete the specified heatmap", is_flag=True) +@click.option("--delete", help="Delete the specified heatmap.", is_flag=True) @optgroup.group("User selection", cls=RequiredMutuallyExclusiveOptionGroup) -@optgroup.option("--id", "-i", "id_", help="database ID of the user", type=int) -@optgroup.option("--email", "-e", help="email of the user") +@optgroup.option("--id", "-i", "id_", help="Database ID of the user.", type=int) +@optgroup.option("--email", "-e", help="Email address of the user.") @click.pass_context -def cmd_hittekaart( +def cmd_user_hittekaart( ctx: click.Context, config: str, modes: list[str], @@ -273,7 +319,7 @@ def cmd_hittekaart( user_manager.tilehunt_path().unlink(missing_ok=True) return - click.echo(f"Generating overlay maps for {user.name}...") + click.echo(f"Generating overlay maps for {click.style(user.name, fg=FG_USER_NAME)}...") for mode in modes: hittekaart.generate_for( @@ -282,8 +328,119 @@ def cmd_hittekaart( click.echo(f"Generated {mode.value}") +@cli.group("track") +def cmd_track(): + """Management functions for tracks.""" + + +@cmd_track.command("list") +@config_option +def cmd_track_list(config: str): + """List all tracks that are present in the system.""" + env = setup(config) + total_size = 0 + total_tracks = 0 + with env["request"].tm: + dbsession = env["request"].dbsession + data_manager: DataManager = env["request"].data_manager + tracks = dbsession.execute(select(models.Track)).scalars() + for track in tracks: + total_tracks += 1 + try: + track_size = data_manager.open(track.id).size() + except FileNotFoundError: + size = "---" + else: + total_size += track_size + size = util.human_size(track_size) + size = click.style(f"{size:>10}", fg=FG_TRACK_SIZE) + owner_name = click.style(track.owner.name, fg=FG_USER_NAME) + owner_email = click.style(track.owner.email, fg=FG_USER_EMAIL) + track_title = click.style(track.title, fg=FG_TRACK_TITLE) + click.echo(f"{track.id:>4} - {size} - {owner_name} <{owner_email}> - {track_title}") + click.echo("-" * 80) + click.echo( + f"Total: {total_tracks} - {click.style(util.human_size(total_size), fg=FG_TRACK_SIZE)}" + ) + + +@cmd_track.command("del") +@config_option +@click.option("--force", "-f", help="Override the safety check.", is_flag=True) +@click.option("--id", "-i", "id_", help="Database ID of the track.", type=int, required=True) +@click.pass_context +def cmd_track_del( + ctx: click.Context, + config: str, + force: bool, + id_: Optional[int], +): + """Delete a track. + + This command deletes the track as well as any images and comments + associated with it. + + This command is destructive and irreversibly deletes data. + """ + env = setup(config) + query = select(models.Track).filter_by(id=id_) + with env["request"].tm: + dbsession = env["request"].dbsession + track = dbsession.execute(query).scalar_one_or_none() + if track is None: + click.echo("Error: No such track found.", err=True) + ctx.exit(EXIT_FAILURE) + click.secho(track.title, fg=FG_TRACK_TITLE) + if not force: + if not click.confirm("Really delete this track?"): + click.echo("Aborted by user.") + ctx.exit(EXIT_FAILURE) + try: + data = env["request"].data_manager.open(track.id) + except FileNotFoundError: + LOGGER.warning("Data directory not found for track - ignoring") + else: + data.purge() + dbsession.delete(track) + click.echo("Track deleted") + + +@cli.command("maintenance-mode") +@config_option +@click.option("--disable", help="Disable the maintenance mode.", is_flag=True) +@click.argument("reason", required=False) +@click.pass_context +def cmd_maintenance_mode(ctx: click.Context, config: str, disable: bool, reason: Optional[str]): + """Controls the maintenance mode. + + When REASON is given, enables the maintenance mode with the given text as + reason. + + With neither --disable nor REASON given, just checks the state of the + maintenance mode. + """ + env = setup(config) + data_manager = env["request"].data_manager + if disable and reason: + click.echo("Cannot enable and disable maintenance mode at the same time", err=True) + ctx.exit(EXIT_FAILURE) + elif not disable and not reason: + maintenance = data_manager.maintenance_mode() + if maintenance is None: + click.echo("Maintenance mode is disabled") + else: + click.echo(f"Maintenance mode is enabled: {maintenance}") + elif disable: + (data_manager.data_dir / "MAINTENANCE").unlink() + else: + (data_manager.data_dir / "MAINTENANCE").write_text(reason, encoding="utf-8") + + @cli.command("version") def cmd_version(): """Show the installed fietsboek version.""" name = __name__.split(".", 1)[0] print(f"{name} {__VERSION__}") + + +__all__ = ["cli"] diff --git a/fietsboek/static/theme.css b/fietsboek/static/theme.css index d2e7f9d..d97192b 100644 --- a/fietsboek/static/theme.css +++ b/fietsboek/static/theme.css @@ -9,6 +9,10 @@ strong { font-weight: 700; } +.brand-link { + text-decoration: none; +} + .badge-container { width: 50px; height: 50px; diff --git a/fietsboek/static/theme.css.map b/fietsboek/static/theme.css.map index 9d4524a..632d85f 100644 --- a/fietsboek/static/theme.css.map +++ b/fietsboek/static/theme.css.map @@ -1 +1 @@ -{"version":3,"sourceRoot":"","sources":["../../asset-sources/theme.scss"],"names":[],"mappings":"AAAA;EACE;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EAqCE;EACA;EACA;EACA;EACA;EACA;;AAzCA;EACE;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAWJ;EACI;;;AAGJ;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;;AAEA;EACE;EACA;;AAGF;EACE;;;AAIJ;AACA;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE","file":"theme.css"}
\ No newline at end of file +{"version":3,"sourceRoot":"","sources":["../../asset-sources/theme.scss"],"names":[],"mappings":"AAAA;EACE;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EAqCE;EACA;EACA;EACA;EACA;EACA;;AAzCA;EACE;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAWJ;EACI;;;AAGJ;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;;AAEA;EACE;EACA;;AAGF;EACE;;;AAIJ;AACA;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE","file":"theme.css"}
\ No newline at end of file diff --git a/fietsboek/templates/home.jinja2 b/fietsboek/templates/home.jinja2 index e89c9bd..53eefc7 100644 --- a/fietsboek/templates/home.jinja2 +++ b/fietsboek/templates/home.jinja2 @@ -2,7 +2,16 @@ {% block content %} <div class="container"> - <h1>{{ _("page.home.title") }}</h1> + <div class="clearfix"> + <h1 class="float-start">{{ _("page.home.title") }}</h1> + {% if summary %} + <div class="float-end mb-2 mt-2"> + <button type="button" class="btn btn-outline-dark" id="changeHomeSorting"> + {% if sorted_ascending %}<i class="bi bi-sort-down"></i>{% else %}<i class="bi bi-sort-up"></i>{% endif %} + </button> + </div> + {% endif %} + </div> {% if unfinished_uploads %} <div class="alert alert-warning"> {{ _("page.home.unfinished_uploads") }} @@ -14,11 +23,6 @@ </div> {% endif %} {% if summary %} - <div class="d-flex justify-content-end mb-2"> - <button type="button" class="btn btn-outline-dark" id="changeHomeSorting"> - {% if sorted_ascending %}<i class="bi bi-sort-down"></i>{% else %}<i class="bi bi-sort-up"></i>{% endif %} - </button> - </div> <div class="list-group list-group-root"> {% for year in summary %} <a class="list-group-item list-group-item-primary"> diff --git a/fietsboek/templates/layout.jinja2 b/fietsboek/templates/layout.jinja2 index b6fcea0..3206d97 100644 --- a/fietsboek/templates/layout.jinja2 +++ b/fietsboek/templates/layout.jinja2 @@ -37,7 +37,9 @@ const Legende = false; <body> <nav class="navbar navbar-dark bg-dark navbar-expand-lg"> <div class="container-fluid"> - <span class="navbar-brand mb-0 h1"><i class="bi bi-bicycle"></i> Fietsboek</span> + <a href="{{ request.route_url('home') }}" class="brand-link"> + <span class="navbar-brand mb-0 h1"><i class="bi bi-bicycle"></i> Fietsboek</span> + </a> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar" aria-controls="navbar" aria-expanded="false" aria-label="{{ _('page.navbar.toggle') }}"> <span class="navbar-toggler-icon"></span> </button> @@ -117,7 +119,7 @@ const Legende = false; <!-- Placed at the end of the document so the pages load faster --> <script src="{{request.static_url('fietsboek:static/bootstrap.bundle.min.js')}}"></script> <!-- Pre-load leaflet Javascript. This lets us use Leaflet on any page, without relying on GPXViewer to load it --> - <script src="{{request.static_url('fietsboek:static/GM_Utils/leaflet/leaflet.js')}}"> + <script src="{{request.static_url('fietsboek:static/GM_Utils/leaflet/leaflet.js')}}"></script> <!-- Our patch to the GPX viewer, load before the actual GPX viewer --> <script src="{{request.static_url('fietsboek:static/osm-monkeypatch.js')}}"></script> <!-- Jürgen Berkemeier's GPX viewer --> diff --git a/fietsboek/templates/login.jinja2 b/fietsboek/templates/login.jinja2 index 7a9bf07..bdf2c99 100644 --- a/fietsboek/templates/login.jinja2 +++ b/fietsboek/templates/login.jinja2 @@ -41,6 +41,8 @@ <div class="row justify-content-center"> <div class="col-auto mb-3"> <a href="{{ request.route_url("password-reset") }}">{{ _("page.login.forgot_password") }}</a> + • + <a href="{{ request.route_url("resend-verification") }}">{{ _("page.login.resend_verification") }}</a> </div> </div> </form> diff --git a/fietsboek/templates/resend_verification.jinja2 b/fietsboek/templates/resend_verification.jinja2 new file mode 100644 index 0000000..cc56854 --- /dev/null +++ b/fietsboek/templates/resend_verification.jinja2 @@ -0,0 +1,22 @@ +{% extends "layout.jinja2" %} +{% import "util.jinja2" as util with context %} +{% block content %} +<div class="container"> + <h1>{{ _("page.resend_verification.title") }}</h1> + <p>{{ _("page.resend_verification.info") }}</p> + <form method="POST"> + <div class="row align-items-center"> + <div class="col-lg-5"> + <div class="form-floating"> + <input type="email" id="resendEmail" name="email" class="form-control" placeholder="x"> + <label for="resendEmail">{{ _("page.resend_verification.email") }}</label> + </div> + </div> + {{ util.hidden_csrf_input() }} + <div class="col-lg-4"> + <button class="btn btn-primary">{{ _("page.resend_verification.request") }}</button> + </div> + </div> + </form> +</div> +{% endblock %} diff --git a/fietsboek/util.py b/fietsboek/util.py index a296151..04f5551 100644 --- a/fietsboek/util.py +++ b/fietsboek/util.py @@ -1,5 +1,6 @@ """Various utility functions.""" import datetime +import html import importlib.resources import os import re @@ -65,9 +66,9 @@ def safe_markdown(md_source: str) -> Markup: :param md_source: The markdown source. :return: The safe HTML transformed version. """ - html = markdown.markdown(md_source, output_format="html") - html = nh3.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES) - return Markup(html) + converted = markdown.markdown(md_source, output_format="html") + converted = nh3.clean(converted, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES) + return Markup(converted) def fix_iso_timestamp(timestamp: str) -> str: @@ -202,6 +203,29 @@ def mps_to_kph(mps: float) -> float: return mps / 1000 * 60 * 60 +def human_size(num_bytes: int) -> str: + """Formats the amount of bytes for human consumption. + + :param num_bytes: The amount of bytes. + :return: The formatted amount. + """ + num_bytes = float(num_bytes) + suffixes = ["B", "KiB", "MiB", "GiB"] + prefix = "" + if num_bytes < 0: + prefix = "-" + num_bytes = -num_bytes + for suffix in suffixes: + if num_bytes < 1024 or suffix == suffixes[-1]: + if suffix == "B": + # Don't do the decimal point for bytes + return f"{prefix}{int(num_bytes)} {suffix}" + return f"{prefix}{num_bytes:.1f} {suffix}" + num_bytes /= 1024 + # Unreachable: + return "" + + def month_name(request: Request, month: int) -> str: """Returns the localized name for the month with the given number. @@ -354,6 +378,22 @@ def tile_url(request: Request, route_name: str, **kwargs: str) -> str: return route.replace("__x__", "{x}").replace("__y__", "{y}").replace("__z__", "{z}") +def encode_gpx(gpx: gpxpy.gpx.GPX) -> bytes: + """Encodes a GPX in-memory representation to the XML representation. + + This ensures that the resulting XML file is valid. + + Returns the contents of the XML as bytes, ready to be written to disk. + + :param gpx: The GPX file to encode. Might be modified! + :return: The encoded GPX content. + """ + for track in gpx.tracks: + if track.link: + track.link = html.escape(track.link) + return gpx.to_xml().encode("utf-8") + + def secure_filename(filename: str) -> str: r"""Pass it a filename and it will return a secure version of it. This filename can then safely be stored on a regular file system and passed diff --git a/fietsboek/views/account.py b/fietsboek/views/account.py index 39a62e5..5400f0a 100644 --- a/fietsboek/views/account.py +++ b/fietsboek/views/account.py @@ -3,8 +3,7 @@ from pyramid.httpexceptions import HTTPForbidden, HTTPFound from pyramid.i18n import TranslationString as _ from pyramid.view import view_config -from .. import email, models, util -from ..models.user import TokenType +from .. import actions, models, util @view_config( @@ -63,25 +62,7 @@ def do_create_account(request): user.set_password(password) request.dbsession.add(user) - token = models.Token.generate(user, TokenType.VERIFY_EMAIL) - request.dbsession.add(token) - - message = email.prepare_message( - request.config.email_from, - user.email, - request.localizer.translate(_("email.verify_mail.subject")), - ) - message.set_content( - request.localizer.translate(_("email.verify.text")).format( - request.route_url("use-token", uuid=token.uuid) - ) - ) - email.send_message( - request.config.email_smtp_url, - request.config.email_username, - request.config.email_password.get_secret_value(), - message, - ) + actions.send_verification_token(request, user) request.session.flash(request.localizer.translate(_("flash.a_confirmation_link_has_been_sent"))) return HTTPFound(request.route_url("login")) diff --git a/fietsboek/views/default.py b/fietsboek/views/default.py index 08f32ce..f9942c3 100644 --- a/fietsboek/views/default.py +++ b/fietsboek/views/default.py @@ -4,25 +4,25 @@ from pyramid.httpexceptions import HTTPFound, HTTPNotFound from pyramid.i18n import TranslationString as _ from pyramid.interfaces import ISecurityPolicy from pyramid.renderers import render_to_response +from pyramid.request import Request +from pyramid.response import Response from pyramid.security import forget, remember from pyramid.view import view_config from sqlalchemy import select from sqlalchemy.exc import NoResultFound from sqlalchemy.orm import aliased -from .. import email, models, summaries, util +from .. import actions, email, models, summaries, util from ..models.track import TrackType, TrackWithMetadata -from ..models.user import PasswordMismatch, TokenType +from ..models.user import TOKEN_LIFETIME, PasswordMismatch, TokenType @view_config(route_name="home", renderer="fietsboek:templates/home.jinja2") -def home(request): +def home(request: Request) -> Response: """Renders the home page. :param request: The Pyramid request. - :type request: pyramid.request.Request :return: The HTTP response. - :rtype: pyramid.response.Response """ if not request.identity: # See if the admin set a custom home page @@ -73,13 +73,11 @@ def home(request): @view_config(route_name="static-page", renderer="fietsboek:templates/static-page.jinja2") -def static_page(request): +def static_page(request: Request) -> Response: """Renders a static page. :param request: The Pyramid request. - :type request: pyramid.request.Request :return: The HTTP response. - :rtype: pyramid.response.Response """ page = request.pages.find(request.matchdict["slug"], request) if page is None: @@ -92,26 +90,22 @@ def static_page(request): @view_config(route_name="login", renderer="fietsboek:templates/login.jinja2", request_method="GET") -def login(request): +def login(request: Request) -> Response: """Renders the login page. :param request: The Pyramid request. - :type request: pyramid.request.Request :return: The HTTP response. - :rtype: pyramid.response.Response """ # pylint: disable=unused-argument return {} @view_config(route_name="login", request_method="POST") -def do_login(request): +def do_login(request: Request) -> Response: """Endpoint for the login form. :param request: The Pyramid request. - :type request: pyramid.request.Request :return: The HTTP response. - :rtype: pyramid.response.Response """ query = models.User.query_by_email(request.params["email"]) try: @@ -139,13 +133,11 @@ def do_login(request): @view_config(route_name="logout") -def logout(request): +def logout(request: Request) -> Response: """Logs the user out. :param request: The Pyramid request. - :type request: pyramid.request.Request :return: The HTTP response. - :rtype: pyramid.response.Response """ request.session.flash(request.localizer.translate(_("flash.logged_out"))) headers = forget(request) @@ -157,26 +149,22 @@ def logout(request): request_method="GET", renderer="fietsboek:templates/request_password.jinja2", ) -def password_reset(request): +def password_reset(request: Request) -> Response: """Form to request a new password. :param request: The Pyramid request. - :type request: pyramid.request.Request :return: The HTTP response. - :rtype: pyramid.response.Response """ # pylint: disable=unused-argument return {} @view_config(route_name="password-reset", request_method="POST") -def do_password_reset(request): +def do_password_reset(request: Request) -> Response: """Endpoint for the password request form. :param request: The Pyramid request. - :type request: pyramid.request.Request :return: The HTTP response. - :rtype: pyramid.response.Response """ query = models.User.query_by_email(request.params["email"]) user = request.dbsession.execute(query).scalar_one_or_none() @@ -208,15 +196,48 @@ def do_password_reset(request): return HTTPFound(request.route_url("password-reset")) +@view_config( + route_name="resend-verification", + request_method="GET", + renderer="fietsboek:templates/resend_verification.jinja2", +) +def resend_verification(_request: Request) -> Response: + """Form to request a new verification mail. + + :param request: The Pyramid request. + :return: The HTTP response. + """ + return {} + + +@view_config(route_name="resend-verification", request_method="POST") +def do_resend_verification(request: Request) -> Response: + """Endpoint for the verification resend form. + + :param request: The Pyramid request. + :return: The HTTP response. + """ + query = models.User.query_by_email(request.params["email"]) + user = request.dbsession.execute(query).scalar_one_or_none() + if user is None or user.is_verified: + request.session.flash( + request.localizer.translate(_("flash.resend_verification_email_fail")) + ) + return HTTPFound(request.route_url("resend-verification")) + + actions.send_verification_token(request, user) + request.session.flash(request.localizer.translate(_("flash.verification_token_generated"))) + + return HTTPFound(request.route_url("login")) + + @view_config(route_name="use-token") -def use_token(request): +def use_token(request: Request) -> Response: """Endpoint with which a user can use a token for a password reset or email verification. :param request: The Pyramid request. - :type request: pyramid.request.Request :return: The HTTP response. - :rtype: pyramid.response.Response """ token = request.dbsession.execute( select(models.Token).filter_by(uuid=request.matchdict["uuid"]) @@ -224,6 +245,10 @@ def use_token(request): if token is None: return HTTPNotFound() + if token.age() > TOKEN_LIFETIME: + request.session.flash(request.localizer.translate(_("flash.token_expired"))) + return HTTPFound(request.route_url("home")) + if token.token_type == TokenType.VERIFY_EMAIL: token.user.is_verified = True request.dbsession.delete(token) @@ -243,4 +268,4 @@ def use_token(request): request.dbsession.delete(token) request.session.flash(request.localizer.translate(_("flash.password_updated"))) return HTTPFound(request.route_url("login")) - return None + raise NotImplementedError("No matching action found") diff --git a/fietsboek/views/detail.py b/fietsboek/views/detail.py index f540a11..ec66922 100644 --- a/fietsboek/views/detail.py +++ b/fietsboek/views/detail.py @@ -20,6 +20,17 @@ from ..models.track import TrackWithMetadata LOGGER = logging.getLogger(__name__) +def _sort_key(image_name: str) -> str: + """Returns the "key" by which the image should be sorted. + + :param image_name: Name of the image (on disk). + :return: The value that should be used to sort the image. + """ + if "_" not in image_name: + return image_name + return image_name.split("_", 1)[1] + + @view_config( route_name="details", renderer="fietsboek:templates/details.jinja2", permission="track.view" ) @@ -51,9 +62,13 @@ def details(request): query = select(models.ImageMetadata).filter_by(track=track, image_name=image_name) image_metadata = request.dbsession.execute(query).scalar_one_or_none() if image_metadata: - images.append((img_src, image_metadata.description)) + images.append((_sort_key(image_name), img_src, image_metadata.description)) else: - images.append((img_src, "")) + images.append((_sort_key(image_name), img_src, "")) + + images.sort(key=lambda element: element[0]) + # Strip off the sort key again + images = [(image[1], image[2]) for image in images] with_meta = TrackWithMetadata(track, request.data_manager) return { diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py index e73df42..c7f932d 100644 --- a/fietsboek/views/profile.py +++ b/fietsboek/views/profile.py @@ -16,6 +16,31 @@ from .. import models, util from ..data import UserDataDir from ..models.track import TrackType, TrackWithMetadata +# A well-made transparent tile is actually pretty small (only 116 bytes), which +# is even smaller than our HTTP 404 page. So not only is it more efficient +# (bandwidth-wise) to transfer the transparent PNG, it also means that we can +# set cache headers, which the client will honor. +# +# Since the tile is so small, we've embedded it right here in the source. +# +# The tile is taken and adapted from hittekaart, which in turn took inspiration +# from https://www.mjt.me.uk/posts/smallest-png/ and +# http://www.libpng.org/pub/png/spec/1.2/PNG-Contents.html +# fmt: off +EMPTY_TILE = bytes([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x01, 0x03, 0x00, 0x00, 0x00, 0x66, 0xbc, 0x3a, 0x25, 0x00, 0x00, 0x00, + 0x03, 0x50, 0x4c, 0x54, 0x45, 0x00, 0xff, 0x00, 0x34, 0x5e, 0xc0, 0xa8, + 0x00, 0x00, 0x00, 0x01, 0x74, 0x52, 0x4e, 0x53, 0x00, 0x40, 0xe6, 0xd8, + 0x66, 0x00, 0x00, 0x00, 0x1f, 0x49, 0x44, 0x41, 0x54, 0x68, 0x81, 0xed, + 0xc1, 0x01, 0x0d, 0x00, 0x00, 0x00, 0xc2, 0xa0, 0xf7, 0x4f, 0x6d, 0x0e, + 0x37, 0xa0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbe, 0x0d, + 0x21, 0x00, 0x00, 0x01, 0x9a, 0x60, 0xe1, 0xd5, 0x00, 0x00, 0x00, 0x00, + 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +]) +# fmt: on + @dataclass class CumulativeStats: @@ -157,7 +182,12 @@ def profile(request: Request) -> dict: } -@view_config(route_name="user-tile", request_method="GET", permission="profile.view") +@view_config( + route_name="user-tile", + request_method="GET", + permission="profile.view", + http_cache=datetime.timedelta(hours=1), +) def user_tile(request: Request) -> Response: """Returns a single tile from the user's own overlay maps. @@ -194,7 +224,7 @@ def user_tile(request: Request) -> Response: ) result = result.fetchone() if result is None: - return HTTPNotFound() + return Response(EMPTY_TILE, content_type="image/png") return Response(result[0], content_type="image/png") diff --git a/tests/integration/test_register.py b/tests/integration/test_register.py index af1e313..dc7b00a 100644 --- a/tests/integration/test_register.py +++ b/tests/integration/test_register.py @@ -1,19 +1,28 @@ import re +import pytest + import fietsboek.email from fietsboek import models VERIFICATION_LINK_PATTERN = re.compile("http://example.com(/token/[A-Za-z0-9-]+)") -def test_registration_working(testapp, dbsession, route_path, monkeypatch): - """Ensures that a user can register, including using the verification link.""" +@pytest.fixture +def mailcatcher(monkeypatch): + """Monkeypatches the send mail functionality. + + Returns the list of mails sent. + """ mails = [] - def send_message(server_url, username, password, message): + def send_message(_server_url, _username, _passwords, message): mails.append(message) - monkeypatch.setattr(fietsboek.email, "send_message", send_message) + yield mails + +def test_registration_working(testapp, dbsession, route_path, mailcatcher): + """Ensures that a user can register, including using the verification link.""" registration = testapp.get(route_path('create-account')) form = registration.form form['email'] = 'foo-new@bar.com' @@ -23,18 +32,37 @@ def test_registration_working(testapp, dbsession, route_path, monkeypatch): response = form.submit().maybe_follow() assert b'A confirmation link has been sent' in response.body - assert len(mails) == 1 + assert len(mailcatcher) == 1 user = dbsession.execute(models.User.query_by_email('foo-new@bar.com')).scalar_one() assert not user.is_verified - body = mails[0].get_body().get_content() + body = mailcatcher[0].get_body().get_content() token_path = VERIFICATION_LINK_PATTERN.search(body).group(1) testapp.get(token_path) assert user.is_verified +def test_resend_verification_mail(testapp, dbsession, route_path, mailcatcher): + """Ensures that the verification link re-sending works.""" + registration = testapp.get(route_path('create-account')) + form = registration.form + form['email'] = 'foo-new@bar.com' + form['name'] = 'The new Foo' + form['password'] = 'foobarpassword' + form['repeat-password'] = 'foobarpassword' + form.submit().maybe_follow() + + req = testapp.get(route_path('resend-verification')) + req.form['email'] = 'foo-new@bar.com' + req.form.submit().maybe_follow() + + assert len(mailcatcher) == 2 + assert VERIFICATION_LINK_PATTERN.search(mailcatcher[0].get_body().get_content()) + assert VERIFICATION_LINK_PATTERN.search(mailcatcher[1].get_body().get_content()) + + def test_registration_short_password(testapp, route_path): """Ensures that passwords that are too short are rejected.""" registration = testapp.get(route_path('create-account')) diff --git a/tests/integration/test_smoke.py b/tests/integration/test_smoke.py index 5176e88..cead68d 100644 --- a/tests/integration/test_smoke.py +++ b/tests/integration/test_smoke.py @@ -1,7 +1,7 @@ def test_home(testapp): res = testapp.get("/") assert res.status_code == 200 - assert b'<h1>Home</h1>' in res.body + assert b'<h1 class="float-start">Home</h1>' in res.body def test_maintenance(testapp, data_manager): diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py index 1a56911..6dc8e7d 100644 --- a/tests/unit/test_util.py +++ b/tests/unit/test_util.py @@ -88,6 +88,22 @@ def test_tour_metadata(gpx_file): def test_mps_to_kph(mps, kph): assert util.mps_to_kph(mps) == pytest.approx(kph, 0.1) +@pytest.mark.parametrize('num_bytes, expected', [ + (1, '1 B'), + (1023, '1023 B'), + (1024, '1.0 KiB'), + (1536, '1.5 KiB'), + (1024 ** 2, '1.0 MiB'), + (1024 ** 3, '1.0 GiB'), + (0, '0 B'), + # Negative sizes in itself are a bit weird, but they make sense as the + # difference between two size values, so they should still work. + (-1, '-1 B'), + (-1024, '-1.0 KiB'), +]) +def test_human_size(num_bytes, expected): + assert util.human_size(num_bytes) == expected + def test_tile_url(app_request): route_url = util.tile_url(app_request, "tile-proxy", provider="bobby") @@ -44,13 +44,17 @@ commands = flake8 fietsboek [testenv:sphinx] -allowlist_externals = make +allowlist_externals = + make + mkdir changedir={toxinidir}{/}doc commands_pre = poetry install -v --with docs commands = sphinx-apidoc -d 1 -f -M -e -o developer/module/ ../fietsboek "upd_*" make html + mkdir -p _build/man + rst2man.py man/fietsctl.rst _build/man/fietsctl.1 [testenv:mypy] commands_pre = |