ixg_platform/apps/core/tiles.py
ixgadmin d5bc06802e Serve map tiles from our own origin
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.
2026-09-17 13:00:59 +00:00

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