aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Schadt <kingdread@gmx.de>2022-11-17 22:46:42 +0100
committerDaniel Schadt <kingdread@gmx.de>2022-11-17 22:46:42 +0100
commit5607aa64cac1f67169e742d397c2ac8d9c9057a8 (patch)
tree72ab0eff61556d03cd26ffe6f543b429372804c6
parentc1f8b0e55db852e4cfffd75af34bd6f146b35b77 (diff)
downloadfietsboek-5607aa64cac1f67169e742d397c2ac8d9c9057a8.tar.gz
fietsboek-5607aa64cac1f67169e742d397c2ac8d9c9057a8.tar.bz2
fietsboek-5607aa64cac1f67169e742d397c2ac8d9c9057a8.zip
enable custom map layers
This change shuffles around quite a bit because we no longer hardcode our map layers in osm-monkeypatch.js, but instead can pass them through from the Python code. This allows us to dynamically define extra layers, for example to disable layers the admin doesn't want, or to add extra layers that are not yet available. This change for example allows to embed thunderforest maps by adding the following to the config: fietsboek.tile_layers.tf_cycling = TF Cycling fietsboek.tile_layers.tf_cycling.url = https://thunderforest.com/... fietsboek.tile_layers.tf_cycling.access = restricted The next step is to make the Thunderforest maps a bit easier to access (by providing special support for those), but for now, this seems like a good first step and the necessary groundwork.
-rw-r--r--fietsboek/__init__.py8
-rw-r--r--fietsboek/jinja2.py29
-rw-r--r--fietsboek/static/osm-monkeypatch.js74
-rw-r--r--fietsboek/templates/layout.jinja21
-rw-r--r--fietsboek/views/tileproxy.py207
5 files changed, 244 insertions, 75 deletions
diff --git a/fietsboek/__init__.py b/fietsboek/__init__.py
index 6dc435a..21674f5 100644
--- a/fietsboek/__init__.py
+++ b/fietsboek/__init__.py
@@ -51,7 +51,8 @@ def locale_negotiator(request):
def main(global_config, **settings):
""" This function returns a Pyramid WSGI application.
"""
- # pylint: disable=unused-argument
+ # pylint: disable=unused-argument, import-outside-toplevel, cyclic-import
+ from .views import tileproxy
if settings.get('session_key', '<EDIT THIS>') == '<EDIT THIS>':
raise ValueError("Please set a session signing key (session_key) in your settings!")
@@ -71,6 +72,10 @@ def main(global_config, **settings):
settings.get('available_locales', 'en'))
settings['fietsboek.pages'] = aslist(
settings.get('fietsboek.pages', ''))
+ settings['fietsboek.default_tile_layers'] = aslist(
+ settings.get('fietsboek.default_tile_layers',
+ 'osm satellite osmde opentopo topplusopen opensea cycling hiking'))
+ settings['fietsboek.tile_layers'] = tileproxy.extract_tile_layers(settings)
# Load the pages
page_manager = Pages()
@@ -104,5 +109,6 @@ def main(global_config, **settings):
jinja2_env.filters['format_decimal'] = fiets_jinja2.filter_format_decimal
jinja2_env.filters['format_datetime'] = fiets_jinja2.filter_format_datetime
jinja2_env.filters['local_datetime'] = fiets_jinja2.filter_local_datetime
+ jinja2_env.globals['embed_tile_layers'] = fiets_jinja2.global_embed_tile_layers
return config.make_wsgi_app()
diff --git a/fietsboek/jinja2.py b/fietsboek/jinja2.py
index 6043791..ff60660 100644
--- a/fietsboek/jinja2.py
+++ b/fietsboek/jinja2.py
@@ -1,5 +1,6 @@
"""Custom filters for Jinja2."""
import datetime
+import json
import jinja2
from markupsafe import Markup
@@ -77,3 +78,31 @@ def filter_local_datetime(ctx, value):
return Markup(
f'<span class="fietsboek-local-datetime" data-utc-timestamp="{timestamp}">{fallback}</span>'
)
+
+
+def global_embed_tile_layers(request):
+ """Renders the available tile servers for the current user, as a JSON object.
+
+ The returned value is wrapped as a :class:`~markupsafe.Markup` so that it
+ won't get escaped by jinja.
+
+ :param request: The Pyramid request.
+ :type request: pyramid.request.Request
+ :return: The available tile servers.
+ :rtype: markupsafe.Markup
+ """
+ # pylint: disable=import-outside-toplevel,cyclic-import
+ from .views import tileproxy
+ tile_sources = tileproxy.sources_for(request)
+ return Markup(json.dumps([
+ {
+ "name": source.name,
+ "url": request.route_url("tile-proxy", provider=source.key, x="{x}", y="{y}", z="{z}")
+ .replace("%7Bx%7D", "{x}")
+ .replace("%7By%7D", "{y}")
+ .replace("%7Bz%7D", "{z}"),
+ "attribution": source.attribution,
+ "type": source.layer_type.value,
+ }
+ for source in tile_sources
+ ]))
diff --git a/fietsboek/static/osm-monkeypatch.js b/fietsboek/static/osm-monkeypatch.js
index 203a4ca..a8310a2 100644
--- a/fietsboek/static/osm-monkeypatch.js
+++ b/fietsboek/static/osm-monkeypatch.js
@@ -19,77 +19,31 @@
const mycp = '<a href="https://www.j-berkemeier.de/GPXViewer" title="GPX Viewer '+JB.GPX2GM.ver+'">GPXViewer</a> | ';
- this.baseLayers = {};
-
- this.baseLayers["OSM"] = L.tileLayer(BASE_URL + 'tile/osm/{z}/{x}/{y}', {
- maxZoom: 19,
- attribution: mycp+'Map data &copy; <a href="https://www.openstreetmap.org/" target="_blank">OpenStreetMap</a> and contributors <a href="https://creativecommons.org/licenses/by-sa/2.0/" target="_blank">CC-BY-SA</a>'
- });
-
- this.baseLayers["Satellit"] = L.tileLayer(BASE_URL + 'tile/satellite/{z}/{x}/{y}', {
- maxZoom: 21,
- attribution: mycp+'Map data &copy; <a href="https://www.esri.com/">Esri</a>, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
- });
-
- this.baseLayers["OSMDE"] = L.tileLayer(BASE_URL + 'tile/osmde/{z}/{x}/{y}', {
- maxZoom: 19,
- attribution: mycp+'Map data &copy; <a href="https://www.openstreetmap.org/" target="_blank">OpenStreetMap</a> and contributors <a href="https://creativecommons.org/licenses/by-sa/2.0/" target="_blank">CC-BY-SA</a>'
- });
-
- this.baseLayers["Open Topo"] = L.tileLayer(BASE_URL + 'tile/opentopo/{z}/{x}/{y}', {
- maxZoom: 17,
- attribution: mycp+'Kartendaten: © OpenStreetMap-Mitwirkende, SRTM | Kartendarstellung: © <a href="https://opentopomap.org/about">OpenTopoMap</a> (CC-BY-SA)'
- });
+ this.baseLayers = {};
+ this.overlayLayers = {};
- this.baseLayers["TopPlusOpen"] = L.tileLayer(BASE_URL + 'tile/topplusopen/{z}/{x}/{y}', {
- maxZoom: 18,
- attribution: mycp+'Kartendaten: © <a href="https://www.bkg.bund.de/SharedDocs/Produktinformationen/BKG/DE/P-2017/170922-TopPlus-Web-Open.html" target==_blank"">Bundesamt für Kartographie und Geodäsie</a>'
- });
+ for (let layer of TILE_LAYERS) {
+ if (layer.type === "base") {
+ this.baseLayers[layer.name] = L.tileLayer(layer.url, {
+ maxZoom: layer.zoom,
+ attribution: layer.attribution,
+ });
+ } else if (layer.type === "overlay") {
+ this.overlayLayers[layer.name] = L.tileLayer(layer.url, {
+ attribution: layer.attribution,
+ });
+ }
+ }
// https://tileserver.4umaps.com/${z}/${x}/${y}.png
// zoomlevel 16
// https://www.4umaps.com/
- if(JB.GPX2GM.OSM_Cycle_Api_Key && JB.GPX2GM.OSM_Cycle_Api_Key.length>0) {
- this.baseLayers["Cycle"] = L.tileLayer('https://{s}.tile.thunderforest.com/cycle/{z}/{x}/{y}.png?apikey='+JB.GPX2GM.OSM_Cycle_Api_Key, {
- maxZoom: 22,
- attribution: mycp+'Map data &copy; <a href="https://www.thunderforest.com/" target="_blank">OpenCycleMap</a> and contributors <a href="https://creativecommons.org/licenses/by-sa/2.0/" target="_blank">CC-BY-SA</a>'
- });
- }
-
- if(JB.GPX2GM.OSM_Landscape_Api_Key && JB.GPX2GM.OSM_Landscape_Api_Key.length>0) {
- this.baseLayers["Landscape"] = L.tileLayer('https://{s}.tile.thunderforest.com/landscape/{z}/{x}/{y}.png?apikey='+JB.GPX2GM.OSM_Landscape_Api_Key, {
- maxZoom: 22,
- attribution: mycp+'Map data &copy; <a href="https://www.thunderforest.com/" target="_blank">OpenLandscapeMap</a> and contributors <a href="https://creativecommons.org/licenses/by-sa/2.0/" target="_blank">CC-BY-SA</a>'
- });
- }
-
- if(JB.GPX2GM.OSM_Outdoors_Api_Key && JB.GPX2GM.OSM_Outdoors_Api_Key.length>0) {
- this.baseLayers["Outdoors"] = L.tileLayer('https://{s}.tile.thunderforest.com/outdoors/{z}/{x}/{y}.png?apikey='+JB.GPX2GM.OSM_Outdoors_Api_Key, {
- maxZoom: 22,
- attribution: mycp+'Map data &copy; <a href="https://www.thunderforest.com/" target="_blank">OpenOutdoorsMap</a> and contributors <a href="https://creativecommons.org/licenses/by-sa/2.0/" target="_blank">CC-BY-SA</a>'
- });
- }
-
this.baseLayers[JB.GPX2GM.strings[JB.GPX2GM.parameters.doclang].noMap]= L.tileLayer(JB.GPX2GM.Path+"Icons/Grau256x256.png", {
maxZoom: 22,
attribution: mycp
});
- this.overlayLayers = {};
-
- this.overlayLayers["Open Sea"] = L.tileLayer(BASE_URL + 'tile/opensea/{z}/{x}/{y}', {
- attribution: 'Kartendaten: © <a href="http://www.openseamap.org">OpenSeaMap</a> contributors'
- });
-
- this.overlayLayers["Hiking"] = L.tileLayer(BASE_URL + 'tile/hiking/{z}/{x}/{y}', {
- attribution: '&copy; <a href="http://waymarkedtrails.org">Sarah Hoffmann</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)',
- });
-
- this.overlayLayers["Cycling"] = L.tileLayer(BASE_URL + 'tile/cycling/{z}/{x}/{y}', {
- attribution: '&copy; <a href="http://waymarkedtrails.org">Sarah Hoffmann</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)',
- });
-
this.layerNameTranslate = {
satellit: "Satellit",
satellite: "Satellit",
diff --git a/fietsboek/templates/layout.jinja2 b/fietsboek/templates/layout.jinja2
index bbd84f8..bf30143 100644
--- a/fietsboek/templates/layout.jinja2
+++ b/fietsboek/templates/layout.jinja2
@@ -20,6 +20,7 @@
<script>
const FRIENDS_URL = {{ request.route_url('json-friends') | tojson }};
+const TILE_LAYERS = {{ embed_tile_layers(request) }};
const BASE_URL = {{ request.route_url('home') | tojson }};
const LOCALE = {{ request.localizer.locale_name.replace('_', '-') | tojson }};
{% if request.registry.settings.get("thunderforest.api_key") %}
diff --git a/fietsboek/views/tileproxy.py b/fietsboek/views/tileproxy.py
index 5195c91..42e1a67 100644
--- a/fietsboek/views/tileproxy.py
+++ b/fietsboek/views/tileproxy.py
@@ -9,6 +9,10 @@ Additionally, this protects the users' IP, as only fietsboek can see it.
import datetime
import random
import logging
+import re
+from enum import Enum
+from typing import NamedTuple
+from itertools import chain
from pyramid.view import view_config
from pyramid.response import Response
@@ -20,23 +24,152 @@ from requests.exceptions import ReadTimeout
from .. import __VERSION__
+class LayerType(Enum):
+ """Enum to distinguish base layers and overlay layers."""
+ BASE = "base"
+ OVERLAY = "overlay"
+
+
+class LayerAccess(Enum):
+ """Enum discerning whether a layer is publicly accessible or restriced to
+ logged-in users.
+
+ Note that in the future, a finer-grained distinction might be possible.
+ """
+ PUBLIC = "public"
+ RESTRICTED = "restricted"
+
+
+class TileSource(NamedTuple):
+ """Represents a remote server that can provide tiles to us."""
+ key: str
+ """Key to indicate this source in URLs."""
+ name: str
+ """Human-readable name of the source."""
+ url_template: str
+ """URL with placeholders."""
+ layer_type: LayerType
+ """Type of this layer."""
+ zoom: int
+ """Max zoom of this layer."""
+ access: LayerAccess
+ """Access restrictions to use this layer."""
+ attribution: str
+ """Attribution string."""
+
+
LOGGER = logging.getLogger(__name__)
-TILE_SOURCES = {
+
+def _href(url, text):
+ return f'<a href="{url}" target="_blank">{text}</a>'
+
+
+_jb_copy = _href("https://www.j-berkemeier.de/GPXViewer", "GPXViewer")
+
+
+DEFAULT_TILE_LAYERS = [
# Main base layers
- 'osm': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
- 'osmde': 'https://{s}.tile.openstreetmap.de/tiles/osmde/{z}/{x}/{y}.png',
- 'opentopo': 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
- 'topplusopen': 'https://sgx.geodatenzentrum.de/wmts_topplus_open/tile/'
- '1.0.0/web/default/WEBMERCATOR/{z}/{y}/{x}.png',
- 'satellite': 'https://server.arcgisonline.com/ArcGIS/rest/services/'
- 'World_Imagery/MapServer/tile/{z}/{y}/{x}',
+ TileSource(
+ 'osm',
+ 'OSM',
+ 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+ LayerType.BASE,
+ 19,
+ LayerAccess.PUBLIC,
+ ''.join([
+ _jb_copy, ' | Map data &copy; ',
+ _href("https://www.openstreetmap.org/", "OpenStreetMap"), ' and contributors ',
+ _href("https://creativecommons.org/licenses/by-sa/2.0/", "CC-BY-SA"),
+ ]),
+ ),
+ TileSource(
+ 'satellite',
+ 'Satellit',
+ 'https://server.arcgisonline.com/ArcGIS/rest/services/'
+ 'World_Imagery/MapServer/tile/{z}/{y}/{x}',
+ LayerType.BASE,
+ 21,
+ LayerAccess.PUBLIC,
+ ''.join([
+ _jb_copy, ' | Map data &copy; ', _href("https://www.esri.com", "Esri"),
+ ', i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, ',
+ 'IGP, UPR-EGP, and the GIS User Community',
+ ]),
+ ),
+ TileSource(
+ 'osmde',
+ 'OSMDE',
+ 'https://{s}.tile.openstreetmap.de/tiles/osmde/{z}/{x}/{y}.png',
+ LayerType.BASE,
+ 19,
+ LayerAccess.PUBLIC,
+ ''.join([
+ _jb_copy, ' | Map data &copy; ',
+ _href("https://www.openstreetmap.org/", "OpenStreetMap"), ' and contributors ',
+ _href("https://creativecommons.org/licenses/by-sa/2.0/", "CC-BY-SA")
+ ]),
+ ),
+ TileSource(
+ 'opentopo',
+ 'Open Topo',
+ 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
+ LayerType.BASE,
+ 17,
+ LayerAccess.PUBLIC,
+ ''.join([
+ _jb_copy,
+ ' | Kartendaten: © OpenStreetMap-Mitwirkende, SRTM | Kartendarstellung: © ',
+ _href("https://opentopomap.org/about", "OpenTopoMap"), ' (CC-BY-SA)',
+ ]),
+ ),
+ TileSource(
+ 'topplusopen',
+ 'TopPlusOpen',
+ 'https://sgx.geodatenzentrum.de/wmts_topplus_open/tile/'
+ '1.0.0/web/default/WEBMERCATOR/{z}/{y}/{x}.png',
+ LayerType.BASE,
+ 18,
+ LayerAccess.PUBLIC,
+ ''.join([
+ _jb_copy, ' | Kartendaten: © ',
+ _href("https://www.bkg.bund.de/SharedDocs/Produktinformationen"
+ "/BKG/DE/P-2017/170922-TopPlus-Web-Open.html",
+ "Bundesamt für Kartographie und Geodäsie"),
+ ]),
+ ),
# Overlay layers
- 'opensea': 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
- 'hiking': 'https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png',
- 'cycling': 'https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png',
-}
+ TileSource(
+ 'opensea',
+ 'OpenSea',
+ 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
+ LayerType.OVERLAY,
+ None,
+ LayerAccess.PUBLIC,
+ 'Kartendaten: © <a href="http://www.openseamap.org">OpenSeaMap</a> contributors',
+ ),
+ TileSource(
+ 'hiking',
+ 'Hiking',
+ 'https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png',
+ LayerType.OVERLAY,
+ None,
+ LayerAccess.PUBLIC,
+ f'&copy; {_href("http://waymarkedtrails.org", "Sarah Hoffmann")} '
+ f'({_href("https://creativecommons.org/licenses/by-sa/3.0/", "CC-BY-SA")})',
+ ),
+ TileSource(
+ 'cycling',
+ 'Cycling',
+ 'https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png',
+ LayerType.OVERLAY,
+ None,
+ LayerAccess.PUBLIC,
+ f'&copy; {_href("http://waymarkedtrails.org", "Sarah Hoffmann")} '
+ f'({_href("https://creativecommons.org/licenses/by-sa/3.0/", "CC-BY-SA")})',
+ ),
+]
TTL = datetime.timedelta(days=7)
"""Time to live of cached tiles."""
@@ -61,7 +194,8 @@ def tile_proxy(request):
:rtype: pyramid.response.Response
"""
provider = request.matchdict['provider']
- if provider not in TILE_SOURCES:
+ tile_sources = {source.key: source for source in sources_for(request)}
+ if provider not in tile_sources:
return HTTPBadRequest("Invalid provider")
x, y, z = (int(request.matchdict['x']), int(request.matchdict['y']),
@@ -81,7 +215,7 @@ def tile_proxy(request):
LOGGER.debug("Aborted attempt to contact %s due to previous timeouts", provider)
return HTTPGatewayTimeout(f"Avoiding request to {provider}")
- url = TILE_SOURCES[provider].format(x=x, y=y, z=z, s=random.choice("abc"))
+ url = tile_sources[provider].url_template.format(x=x, y=y, z=z, s=random.choice("abc"))
headers = {
"user-agent": f"Fietsboek-Tile-Proxy/{__VERSION__}",
}
@@ -99,3 +233,48 @@ def tile_proxy(request):
else:
request.redis.set(cache_key, resp.content, ex=TTL)
return Response(resp.content, content_type=resp.headers.get("Content-type", content_type))
+
+
+def sources_for(request):
+ """Returns all eligible tile sources for the given request.
+
+ :param request: The Pyramid request.
+ :type request: pyramid.request.Request
+ :return: A list of tile sources.
+ :rtype: list[TileSource]
+ """
+ settings = request.registry.settings
+ return [
+ source for source in chain(
+ (default_layer for default_layer in DEFAULT_TILE_LAYERS
+ if default_layer.key in settings["fietsboek.default_tile_layers"]),
+ settings["fietsboek.tile_layers"]
+ )
+ if source.access == LayerAccess.PUBLIC or request.identity is not None
+ ]
+
+
+def extract_tile_layers(settings):
+ """Extract all defined tile layers from the settings.
+
+ :param settings: The application settings.
+ :type settings: dict
+ :return: A list of extracted tile sources.
+ :rtype: list[TileSource]
+ """
+ layers = []
+ for key in settings.keys():
+ match = re.match("^fietsboek\\.tile_layer\\.([A-Za-z0-9_-]+)$", key)
+ if not match:
+ continue
+
+ provider_id = match.group(1)
+ name = settings[key]
+ url = settings[f"{key}.url"]
+ layer_type = LayerType(settings.get(f"{key}.type", "base"))
+ zoom = int(settings.get(f"{key}.zoom", 22))
+ attribution = settings.get(f"{key}.attribution", _jb_copy)
+ access = LayerAccess(settings.get(f"{key}.access", "public"))
+
+ layers.append(TileSource(provider_id, name, url, layer_type, zoom, access, attribution))
+ return layers