diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | development.ini | 3 | ||||
| -rw-r--r-- | fietsboek/__init__.py | 11 | ||||
| -rw-r--r-- | fietsboek/data.py | 118 | ||||
| -rw-r--r-- | fietsboek/routes.py | 2 | ||||
| -rw-r--r-- | fietsboek/static/fietsboek.js | 53 | ||||
| -rw-r--r-- | fietsboek/static/theme.css | 36 | ||||
| -rw-r--r-- | fietsboek/templates/details.jinja2 | 21 | ||||
| -rw-r--r-- | fietsboek/templates/edit.jinja2 | 4 | ||||
| -rw-r--r-- | fietsboek/templates/edit_form.jinja2 | 22 | ||||
| -rw-r--r-- | fietsboek/templates/finish_upload.jinja2 | 4 | ||||
| -rw-r--r-- | fietsboek/util.py | 69 | ||||
| -rw-r--r-- | fietsboek/views/detail.py | 30 | ||||
| -rw-r--r-- | fietsboek/views/edit.py | 17 | ||||
| -rw-r--r-- | fietsboek/views/upload.py | 6 | 
15 files changed, 389 insertions, 8 deletions
@@ -22,3 +22,4 @@ coverage  test  *.sqlite  .venv/ +/data diff --git a/development.ini b/development.ini index 47e0231..eb33586 100644 --- a/development.ini +++ b/development.ini @@ -18,11 +18,12 @@ pyramid.includes =      pyramid_debugtoolbar  sqlalchemy.url = sqlite:///%(here)s/fietsboek.sqlite +fietsboek.data_dir = %(here)s/data  retry.attempts = 3  email.from = fietsboek@kingdread.de -email.smtp_url = debug://localhost:1025 +email.smtp_url = debug://  available_locales = en de  enable_account_registration = true diff --git a/fietsboek/__init__.py b/fietsboek/__init__.py index 4a28dd4..bd2d705 100644 --- a/fietsboek/__init__.py +++ b/fietsboek/__init__.py @@ -2,6 +2,8 @@  For more information, see the README or the included documentation.  """ +from pathlib import Path +  from pyramid.config import Configurator  from pyramid.session import SignedCookieSessionFactory  from pyramid.csrf import CookieCSRFStoragePolicy @@ -9,6 +11,7 @@ from pyramid.settings import asbool, aslist  from pyramid.i18n import default_locale_negotiator  from .security import SecurityPolicy +from .data import DataManager  from . import jinja2 as fiets_jinja2 @@ -46,6 +49,13 @@ def main(global_config, **settings):      if settings.get('session_key', '<EDIT THIS>') == '<EDIT THIS>':          raise ValueError("Please set a session signing key (session_key) in your settings!") +    if 'fietsboek.data_dir' not in settings: +        raise ValueError("Please set a data directory (fietsboek.data_dir) in your settings!") + +    def data_manager(request): +        data_dir = request.registry.settings["fietsboek.data_dir"] +        return DataManager(Path(data_dir)) +      settings['enable_account_registration'] = asbool(          settings.get('enable_account_registration', 'false'))      settings['available_locales'] = aslist( @@ -63,6 +73,7 @@ def main(global_config, **settings):          config.set_csrf_storage_policy(CookieCSRFStoragePolicy())          config.set_default_csrf_options(require_csrf=True)          config.set_locale_negotiator(locale_negotiator) +        config.add_request_method(data_manager, reify=True)      jinja2_env = config.get_jinja2_environment()      jinja2_env.filters['format_decimal'] = fiets_jinja2.filter_format_decimal diff --git a/fietsboek/data.py b/fietsboek/data.py new file mode 100644 index 0000000..992ddf7 --- /dev/null +++ b/fietsboek/data.py @@ -0,0 +1,118 @@ +"""Data manager for fietsboek. + +Data are objects that belong to a track (such as images), but are not stored in +the database itself. This module makes access to such data objects easier. +""" +import random +import string +import shutil +import uuid + +from .util import secure_filename + + +def generate_filename(filename): +    """Generates a safe-to-use filename for uploads. + +    If possible, tries to keep parts of the original filename intact, such as +    the extension. + +    :param filename: The original filename. +    :type filename: str +    :return: The generated filename. +    :rtype: str +    """ +    if filename: +        good_name = secure_filename(filename) +        if good_name: +            random_prefix = "".join(random.choice(string.ascii_lowercase) for _ in range(5)) +            return f"{random_prefix}_{good_name}" + +    return str(uuid.uuid4()) + + +class DataManager: +    """Data manager. + +    The data manager is usually provided as ``request.data_manager`` and can be +    used to access track's images and other on-disk data. + +    :ivar data_dir: Path to the data folder. +    :vartype data_dir: pathlib.Path +    """ + +    def __init__(self, data_dir): +        self.data_dir = data_dir + +    def _track_data_dir(self, track_id): +        return self.data_dir / "tracks" / str(track_id) + +    def images(self, track_id): +        """Returns a list of images that belong to a track. + +        :param track_id: Numerical ID of the track. +        :type track_id: int +        :return: A list of image IDs. +        :rtype: list[str] +        """ +        image_dir = self._track_data_dir(track_id) / "images" +        if not image_dir.exists(): +            return [] +        images = [] +        for image in image_dir.iterdir(): +            images.append(image.name) +        return images + +    def image_path(self, track_id, image_id): +        """Returns a path to a saved image. + +        :raises FileNotFoundError: If the given image could not be found. +        :param track_id: ID of the track. +        :type track_id: int +        :param image_id: ID of the image. +        :type image_id: str +        :return: A path pointing to the requested image. +        :rtype: pathlib.Path +        """ +        image = self._track_data_dir(track_id) / "images" / secure_filename(image_id) +        if not image.exists(): +            raise FileNotFoundError("The requested image does not exist") +        return image + +    def add_image(self, track_id, image, filename=None): +        """Saves an image to a track. + +        :param track_id: ID of the track. +        :type track_id: int +        :param image: The image, as a file-like object to read from. +        :type image: file +        :param filename: The image's original filename. +        :type filename: str +        :return: The ID of the saved image. +        :rtype: str +        """ +        image_dir = self._track_data_dir(track_id) / "images" +        image_dir.mkdir(parents=True, exist_ok=True) + +        filename = generate_filename(filename) +        path = image_dir / filename +        with open(path, "wb") as fobj: +            shutil.copyfileobj(image, fobj) + +        return filename + +    def delete_image(self, track_id, image_id): +        """Deletes an image from a track. + +        :raises FileNotFoundError: If the given image could not be found. +        :param track_id: ID of the track. +        :type track_id: int +        :param image_id: ID of the image. +        :type image_id: str +        """ +        # Be sure to not delete anything else than the image file +        image_id = secure_filename(image_id) +        if '/' in image_id or '\\' in image_id: +            return +        path = self.image_path(track_id, image_id) +        path.unlink() diff --git a/fietsboek/routes.py b/fietsboek/routes.py index a28f926..9d705c1 100644 --- a/fietsboek/routes.py +++ b/fietsboek/routes.py @@ -35,6 +35,8 @@ def includeme(config):                       factory='fietsboek.models.Track.factory')      config.add_route('add-comment', '/track/{track_id}/comment',                       factory='fietsboek.models.Track.factory') +    config.add_route('image', '/track/{track_id}/images/{image_name}', +                     factory='fietsboek.models.Track.factory')      config.add_route('badge', '/badge/{badge_id}',                       factory='fietsboek.models.Badge.factory') diff --git a/fietsboek/static/fietsboek.js b/fietsboek/static/fietsboek.js index d27e21e..e403628 100644 --- a/fietsboek/static/fietsboek.js +++ b/fietsboek/static/fietsboek.js @@ -69,6 +69,48 @@ function removeFriendClicked(event) {    button.parentNode.parentNode.removeChild(button.parentNode);  } +function imageSelectorChanged(event) { +  console.log(event.target.files); + +  for (var file of event.target.files) { +    const input = document.createElement("input"); +    input.type = "file"; +    input.hidden = true; +    input.name = "image[]"; + +    const transfer = new DataTransfer(); +    transfer.items.add(file); +    input.files = transfer.files; + +    let preview = document.querySelector("#trackImagePreviewBlueprint").cloneNode(true); +    preview.removeAttribute("id"); +    preview.querySelector("img").src = URL.createObjectURL(file); +    preview.querySelector("button.delete-image").addEventListener("click", deleteImageButtonClicked); +    preview.appendChild(input); + +    document.querySelector("#trackImageList").appendChild(preview); +  } + +  event.target.value = ""; +} + +function deleteImageButtonClicked(event) { +  let preview = event.target.closest("div.track-image-preview"); +  /* If this was a image yet to be uploaded, simply remove it */ +  let input = preview.querySelector("input[type=file]"); +  if (input) { +    preview.parentNode.removeChild(preview); +    return; +  } + +  /* Otherwise, we need to remove it but also insert a "delete-image" input */ +  let deleter = preview.querySelector("input[type=hidden]"); +  deleter.removeAttribute("disabled"); +  preview.removeChild(deleter); +  preview.parentNode.appendChild(deleter); +  preview.parentNode.removeChild(preview); +} +  document.addEventListener('DOMContentLoaded', function(event) {    /* Enable the "Add tag" button in the track edit page */    let $ = (selector) => document.querySelector(selector); @@ -150,4 +192,15 @@ document.addEventListener('DOMContentLoaded', function(event) {        $("#archiveDownloadButton").disabled = (checked.length == 0);      });    }); + +  /* Enable the image selector */ +  var button = $("#imageSelector"); +  if (button) { +    button.addEventListener("change", imageSelectorChanged); +  } + +  /* Enable the "delete image" buttons for the already existing images */ +  document.querySelectorAll("button.delete-image").forEach((b) => { +    b.addEventListener("click", deleteImageButtonClicked); +  });  }); diff --git a/fietsboek/static/theme.css b/fietsboek/static/theme.css index 831b1f6..076b519 100644 --- a/fietsboek/static/theme.css +++ b/fietsboek/static/theme.css @@ -18,6 +18,42 @@ strong {    align-items: center;  } +.carousel-item img { +  max-height: 700px; +  margin: auto; +} + +#trackImageList { +  display: flex; +  flex-wrap: wrap; +} + +.track-image-preview button { +  position: absolute; +  z-index: 5; +  background-color: white; +  right: 0px; +} + +.track-image-preview img { +  max-width: 100%; +  max-height: 100%; +  position: absolute; +  top: 50%; +  left: 50%; +  transform: translate(-50%, -50%); +} + +.track-image-preview { +  position: relative; +  width: min(317px, 100%); +  height: calc(9 / 16 * 317px); +  border-radius: 5px; +  border: 1px solid grey; +  margin-right: 5px; +  margin-bottom: 5px; +} +  .track-description img {      max-width: 100%;  } diff --git a/fietsboek/templates/details.jinja2 b/fietsboek/templates/details.jinja2 index 4d88224..2ec62ec 100644 --- a/fietsboek/templates/details.jinja2 +++ b/fietsboek/templates/details.jinja2 @@ -137,6 +137,27 @@    </div>    <hr>    {% endif %} +  {% if images %} +  <div id="trackImageShowcase" class="carousel carousel-dark slide" data-bs-ride="carousel"> +    <div class="carousel-inner"> +      {% for image in images %} +      <div class="carousel-item{% if loop.first %} active{% endif %}"> +        <a href="{{ image }}" target="_blank"> +          <img src="{{ image }}" class="d-block"> +        </a> +      </div> +      {% endfor %} +    </div> +    <button class="carousel-control-prev" type="button" data-bs-target="#trackImageShowcase" data-bs-slide="prev"> +      <span class="carousel-control-prev-icon" aria-hidden="true"></span> +      <span class="visually-hidden">Previous</span> +    </button> +    <button class="carousel-control-next" type="button" data-bs-target="#trackImageShowcase" data-bs-slide="next"> +      <span class="carousel-control-next-icon" aria-hidden="true"></span> +      <span class="visually-hidden">Next</span> +    </button> +  </div> +  {% endif %}    <h2>{{ _("page.details.comments") }}</h2>    {% for comment in track.comments %}    <div class="card mb-3"> diff --git a/fietsboek/templates/edit.jinja2 b/fietsboek/templates/edit.jinja2 index 6053509..4e64f69 100644 --- a/fietsboek/templates/edit.jinja2 +++ b/fietsboek/templates/edit.jinja2 @@ -9,8 +9,8 @@    <div id="mainmap" class="gpxview:{{ request.route_path('gpx', track_id=track.id) }}:OSM" style="width:100%;height:600px">      <noscript><p>{{ _("page.noscript") }}<p></noscript>    </div> -  <form method="POST"> -    {{ edit_form.edit_track(track.title, track.date_raw, track.date_tz or 0, track.visibility, track.description, track.text_tags(), badges, track.tagged_people) }} +  <form method="POST" enctype="multipart/form-data"> +    {{ edit_form.edit_track(track.title, track.date_raw, track.date_tz or 0, track.visibility, track.description, track.text_tags(), badges, track.tagged_people, images) }}      {{ util.hidden_csrf_input() }}      <div class="btn-group" role="group">          <button type="submit" class="btn btn-primary"><i class="bi bi-save"></i> {{ _("page.edit.form.submit") }}</button> diff --git a/fietsboek/templates/edit_form.jinja2 b/fietsboek/templates/edit_form.jinja2 index 3779d5d..0ba933e 100644 --- a/fietsboek/templates/edit_form.jinja2 +++ b/fietsboek/templates/edit_form.jinja2 @@ -1,4 +1,4 @@ -{% macro edit_track(title, date, date_tz, visibility, description, tags, badges, friends) %} +{% macro edit_track(title, date, date_tz, visibility, description, tags, badges, friends, images) %}  <div class="mb-3">    <label for="formTitle" class="form-label">{{ _("page.track.form.title") }}</label>    <input class="form-control" type="text" id="formTitle" name="title" value="{{ title | default("", true) }}"> @@ -87,4 +87,24 @@    <label for="formDesc" class="form-label">{{ _("page.track.form.description") }}</label>    <textarea class="form-control" id="formDesc" name="description" rows="5">{{ description | default("", true) }}</textarea>  </div> +<div class="mb-3"> +  <div id="trackImageList"> +    {% for image in images %} +    <div class="track-image-preview"> +      <button type="button" class="btn-close delete-image" aria-label="{{ _("page.track.form.remove_image") }}"></button> +      <input type="hidden" name="delete-image[]" value="{{ image.name }}" disabled> +      <img src="{{ image.url }}"> +    </div> +    {% endfor %} +  </div> +  <input type="file" name="image[]" id="imageSelector" class="form-control" accept="image/*" style="display:none;" multiple> +  <button type="button" onclick="document.querySelector('#imageSelector').click()" class="btn btn-primary"><i class="bi bi-images"></i> {{ _("page.track.form.select_images") }}</button> +</div> +<!-- Mode hidden templates --> +<div style="display:none;"> +  <div id="trackImagePreviewBlueprint" class="track-image-preview"> +    <button type="button" class="btn-close delete-image" aria-label="{{ _("page.track.form.remove_image") }}"></button> +    <img> +  </div> +</div>  {% endmacro %} diff --git a/fietsboek/templates/finish_upload.jinja2 b/fietsboek/templates/finish_upload.jinja2 index 58c67d4..d6f9f00 100644 --- a/fietsboek/templates/finish_upload.jinja2 +++ b/fietsboek/templates/finish_upload.jinja2 @@ -9,8 +9,8 @@    <div id="mainmap" class="gpxview:{{ request.route_path('preview', upload_id=preview_id) }}:OSM" style="width:100%;height:600px">      <noscript><p>{{ _("page.noscript") }}<p></noscript>    </div> -  <form method="POST"> -    {{ edit_form.edit_track(upload_title, upload_date, upload_date_tz, upload_visibility, upload_description, upload_tags, badges, upload_tagged_people) }} +  <form method="POST" enctype="multipart/form-data"> +    {{ edit_form.edit_track(upload_title, upload_date, upload_date_tz, upload_visibility, upload_description, upload_tags, badges, upload_tagged_people, []) }}      {{ util.hidden_csrf_input() }}      <div class="btn-group" role="group">        <button type="submit" class="btn btn-primary">{{ _("page.upload.form.submit") }}</button> diff --git a/fietsboek/util.py b/fietsboek/util.py index 1014c87..bab2074 100644 --- a/fietsboek/util.py +++ b/fietsboek/util.py @@ -2,6 +2,9 @@  import random  import string  import datetime +import re +import os +import unicodedata  # Compat for Python < 3.9  import importlib_resources @@ -25,6 +28,22 @@ ALLOWED_ATTRIBUTES = dict(bleach.sanitizer.ALLOWED_ATTRIBUTES)  ALLOWED_ATTRIBUTES['img'] = ['alt', 'src'] +_filename_ascii_strip_re = re.compile(r"[^A-Za-z0-9_.-]") +_windows_device_files = ( +    "CON", +    "AUX", +    "COM1", +    "COM2", +    "COM3", +    "COM4", +    "LPT1", +    "LPT2", +    "LPT3", +    "PRN", +    "NUL", +) + +  def safe_markdown(md_source):      """Transform a markdown document into a safe HTML document. @@ -289,3 +308,53 @@ def read_localized_resource(locale_name, path, raise_on_error=False):      if raise_on_error:          raise FileNotFoundError(f"Resource {path!r} not found")      return f"{locale_name}:{path}" + + +def secure_filename(filename): +    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 +    to :func:`os.path.join`.  The filename returned is an ASCII only string +    for maximum portability. +    On windows systems the function also makes sure that the file is not +    named after one of the special device files. + +    >>> secure_filename("My cool movie.mov") +    'My_cool_movie.mov' +    >>> secure_filename("../../../etc/passwd") +    'etc_passwd' +    >>> secure_filename('i contain cool \xfcml\xe4uts.txt') +    'i_contain_cool_umlauts.txt' + +    The function might return an empty filename.  It's your responsibility +    to ensure that the filename is unique and that you abort or +    generate a random filename if the function returned an empty one. + +    :param filename: the filename to secure +    :type filename: str +    :return: The secure filename. +    :rtype: str +    """ +    # Taken from +    # https://github.com/pallets/werkzeug/blob/main/src/werkzeug/utils.py + +    filename = unicodedata.normalize("NFKD", filename) +    filename = filename.encode("ascii", "ignore").decode("ascii") + +    for sep in os.path.sep, os.path.altsep: +        if sep: +            filename = filename.replace(sep, " ") +    filename = str(_filename_ascii_strip_re.sub("", "_".join(filename.split()))).strip( +        "._" +    ) + +    # on nt a couple of special files are present in each folder.  We +    # have to ensure that the target file is not such a filename.  In +    # this case we prepend an underline +    if ( +        os.name == "nt" +        and filename +        and filename.split(".", maxsplit=1)[0].upper() in _windows_device_files +    ): +        filename = f"_{filename}" + +    return filename diff --git a/fietsboek/views/detail.py b/fietsboek/views/detail.py index a15b29d..3953f47 100644 --- a/fietsboek/views/detail.py +++ b/fietsboek/views/detail.py @@ -2,9 +2,9 @@  import datetime  from pyramid.view import view_config -from pyramid.response import Response +from pyramid.response import Response, FileResponse  from pyramid.i18n import TranslationString as _ -from pyramid.httpexceptions import HTTPFound +from pyramid.httpexceptions import HTTPFound, HTTPNotFound  from .. import models, util @@ -22,12 +22,17 @@ def details(request):      track = request.context      description = util.safe_markdown(track.description)      show_edit_link = (track.owner == request.identity) +    images = [ +        request.route_url("image", track_id=track.id, image_name=image) +        for image in request.data_manager.images(track.id) +    ]      return {          'track': track,          'show_edit_link': show_edit_link,          'mps_to_kph': util.mps_to_kph,          'comment_md_to_html': util.safe_markdown,          'description': description, +        'images': images,      } @@ -85,6 +90,27 @@ def badge(request):      return Response(request.context.image) +@view_config(route_name='image', http_cache=3600, permission='track.view') +def image(request): +    """Returns the image data for the requested image. + +    This ensures that the image is sent efficiently, by delegating to the WSGI +    file wrapper if possible. + +    :param request: The Pyramid request. +    :type request: pyramid.request.Request +    :return: The HTTP response. +    :rtype: pyramid.response.Response +    """ +    track = request.context +    try: +        image_path = request.data_manager.image_path(track.id, request.matchdict['image_name']) +    except FileNotFoundError: +        return HTTPNotFound() +    else: +        return FileResponse(image_path, request) + +  @view_config(route_name="add-comment", request_method="POST", permission="track.comment")  def add_comment(request):      """Endpoint to add a comment to a track. diff --git a/fietsboek/views/edit.py b/fietsboek/views/edit.py index aabbcbc..04ed000 100644 --- a/fietsboek/views/edit.py +++ b/fietsboek/views/edit.py @@ -1,5 +1,6 @@  """Views for editing a track."""  import datetime +from collections import namedtuple  from pyramid.view import view_config  from pyramid.httpexceptions import HTTPFound, HTTPBadRequest @@ -10,6 +11,9 @@ from .. import models, util  from ..models.track import Visibility +_Image = namedtuple("_Image", "name url") + +  @view_config(route_name='edit', renderer='fietsboek:templates/edit.jinja2',               permission='track.edit', request_method='GET')  def edit(request): @@ -23,9 +27,14 @@ def edit(request):      track = request.context      badges = request.dbsession.execute(select(models.Badge)).scalars()      badges = [(badge in track.badges, badge) for badge in badges] +    images = [ +        _Image(image, request.route_url("image", track_id=track.id, image_name=image)) +        for image in request.data_manager.images(track.id) +    ]      return {          'track': track,          'badges': badges, +        'images': images,      } @@ -61,4 +70,12 @@ def do_edit(request):      tags = request.params.getall("tag[]")      track.sync_tags(tags) +    for image in request.params.getall("image[]"): +        if image == b"": +            continue +        request.data_manager.add_image(track.id, image.file, image.filename) + +    for image in request.params.getall("delete-image[]"): +        request.data_manager.delete_image(track.id, image) +      return HTTPFound(request.route_url('details', track_id=track.id)) diff --git a/fietsboek/views/upload.py b/fietsboek/views/upload.py index 979291e..4f42b80 100644 --- a/fietsboek/views/upload.py +++ b/fietsboek/views/upload.py @@ -169,6 +169,12 @@ def do_finish_upload(request):      track.ensure_cache()      request.dbsession.add(track.cache) +    # Don't forget to add the images +    for image in request.params.getall("image[]"): +        if image == b"": +            continue +        request.data_manager.add_image(track.id, image.file, image.filename) +      request.session.flash(request.localizer.translate(_("flash.upload_success")))      return HTTPFound(request.route_url('details', track_id=track.id))  | 
