The origin-zone map drew its background from a public tile server, which made it
the only part of the admin page that depended on a third-party domain — and the
part that breaks. Where a viewer's network, proxy or browser filter refuses
tile.openstreetmap.org (the refusal arrives as a 403) the map never draws, while
the page, the Leaflet bundle and the polygon data are all same-origin and fine.
Fetch the tiles server-side and serve them from /tiles/{z}/{x}/{y}.png: cached
on disk, so a page opened repeatedly does not re-fetch, and gated on being
signed in, so the route is not an open tile relay. The upstream stays
configurable via LEAFLET_TILE_URL, and LEAFLET_TILE_PROXY_ENABLED=False puts the
browser back on direct fetches.
When a tile still cannot be fetched, answer an empty tile rather than an error
page and say so under the map, instead of leaving a viewer with a blank grid and
no explanation.
112 lines
3.9 KiB
Python
112 lines
3.9 KiB
Python
"""Map tiles, served from our own origin.
|
|
|
|
The origin-zone map is drawn from a stored polygon, but the background imagery
|
|
comes from a public tile server. That makes the map the only part of the admin
|
|
page that depends on a *third-party* domain, and it is the part that breaks:
|
|
a viewer whose network, proxy or browser filter refuses tile.openstreetmap.org
|
|
(the refusal arrives as a 403) gets a map that never draws, even though the page,
|
|
the Leaflet bundle and the polygon data are all fine and same-origin.
|
|
|
|
So the browser asks us for tiles and we fetch them:
|
|
|
|
browser -> /tiles/{z}/{x}/{y}.png -> <upstream tile server>
|
|
|
|
Two things this buys. The map stops depending on the viewer's network path to a
|
|
third party, and tiles are cached on this server, so a page that is opened
|
|
repeatedly does not re-fetch them.
|
|
|
|
The upstream stays configurable (``LEAFLET_TILE_URL``) because OpenStreetMap's
|
|
tile policy deliberately does not cover heavy commercial use — the same setting
|
|
points the map at a paid provider or a self-hosted tile server without further
|
|
code changes.
|
|
"""
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
from django.conf import settings
|
|
from django.http import HttpResponse, HttpResponseForbidden, HttpResponseNotFound
|
|
|
|
# How long a browser may reuse a tile. Tiles are effectively static.
|
|
BROWSER_CACHE_SECONDS = 60 * 60 * 24 * 7
|
|
|
|
MAX_ZOOM = 19
|
|
FETCH_TIMEOUT_SECONDS = 5
|
|
|
|
# Identify the application to the upstream, as tile providers require.
|
|
UPSTREAM_USER_AGENT = (
|
|
"IXG-Platform/1.0 (internal origin-zone map; contact the platform administrator)"
|
|
)
|
|
|
|
|
|
def browser_tile_url():
|
|
"""The tile URL template the *browser* should use.
|
|
|
|
With the proxy on (the default) this is a same-origin path; with it off, the
|
|
browser talks to the upstream directly, as it did before.
|
|
"""
|
|
if settings.LEAFLET_TILE_PROXY_ENABLED:
|
|
return "/tiles/{z}/{x}/{y}.png"
|
|
return settings.LEAFLET_TILE_URL
|
|
|
|
|
|
def cache_dir():
|
|
return Path(settings.LEAFLET_TILE_CACHE_DIR)
|
|
|
|
|
|
def upstream_url(z, x, y):
|
|
"""Format the configured upstream template for one tile."""
|
|
url = settings.LEAFLET_TILE_URL
|
|
# Some providers use a {s} subdomain placeholder; any label works.
|
|
url = url.replace("{s}", "a")
|
|
return (
|
|
url.replace("{z}", str(z)).replace("{x}", str(x)).replace("{y}", str(y))
|
|
)
|
|
|
|
|
|
def valid_tile(z, x, y):
|
|
"""Reject anything that is not a real tile address for the zoom level."""
|
|
if not 0 <= z <= MAX_ZOOM:
|
|
return False
|
|
span = 2 ** z
|
|
return 0 <= x < span and 0 <= y < span
|
|
|
|
|
|
def tile_view(request, z, x, y):
|
|
"""Serve one tile, from cache when possible, otherwise from the upstream."""
|
|
if not request.user.is_authenticated:
|
|
return HttpResponseForbidden("Authentication required")
|
|
|
|
if not valid_tile(z, x, y):
|
|
return HttpResponseNotFound("No such tile")
|
|
|
|
cached = cache_dir() / str(z) / str(x) / f"{y}.png"
|
|
if cached.is_file():
|
|
return serve(cached.read_bytes())
|
|
|
|
try:
|
|
request_obj = urllib.request.Request(
|
|
upstream_url(z, x, y), headers={"User-Agent": UPSTREAM_USER_AGENT}
|
|
)
|
|
with urllib.request.urlopen(request_obj, timeout=FETCH_TIMEOUT_SECONDS) as resp:
|
|
data = resp.read()
|
|
except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError):
|
|
# The upstream refused or is unreachable. Answer with an empty tile rather
|
|
# than an error page: the map keeps its polygon and its controls, and the
|
|
# viewer sees a blank background instead of a broken grid.
|
|
return HttpResponse(status=204)
|
|
|
|
try:
|
|
cached.parent.mkdir(parents=True, exist_ok=True)
|
|
cached.write_bytes(data)
|
|
except OSError:
|
|
# A read-only or full cache directory must not break the map.
|
|
pass
|
|
|
|
return serve(data)
|
|
|
|
|
|
def serve(data):
|
|
response = HttpResponse(data, content_type="image/png")
|
|
response["Cache-Control"] = f"public, max-age={BROWSER_CACHE_SECONDS}"
|
|
return response
|