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 =  | 
