diff options
-rw-r--r-- | fietsboek/__init__.py | 8 | ||||
-rw-r--r-- | fietsboek/jinja2.py | 29 | ||||
-rw-r--r-- | fietsboek/static/osm-monkeypatch.js | 74 | ||||
-rw-r--r-- | fietsboek/templates/layout.jinja2 | 1 | ||||
-rw-r--r-- | fietsboek/views/tileproxy.py | 207 |
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 © <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 © <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 © <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 © <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 © <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 © <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: '© <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: '© <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 © ', + _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 © ', _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 © ', + _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'© {_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'© {_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 |