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.
This commit is contained in:
ixgadmin 2026-09-17 13:00:59 +00:00
parent cdefbd010c
commit d5bc06802e
6 changed files with 255 additions and 10 deletions

View file

@ -1,9 +1,10 @@
import json
from django.conf import settings
from django.contrib import admin
from django.utils.html import format_html
from apps.core.tiles import browser_tile_url
from .models import (
ProductSpecification, SpecParameter,
Facility, OriginZone, CollectorGroup, HarvestIntake, IntakeBatchLineage,
@ -45,6 +46,10 @@ class OriginZoneAdmin(admin.ModelAdmin):
Display only. It does not validate the geometry and it does not verify
the zone — those are separate pieces of work.
The tile URL is the same-origin path from apps/core/tiles.py, so the map
keeps working when a viewer's network cannot reach a third-party tile
server.
"""
if obj is None:
return "Save this zone, then its area can be drawn here."
@ -60,12 +65,12 @@ class OriginZoneAdmin(admin.ModelAdmin):
return format_html(
'<div id="zone-map" data-geometry="{}" data-tile-url="{}" '
'style="height:420px;max-width:900px;border:1px solid #cbd5e1;'
'border-radius:.375rem"></div>'
'border-radius:.375rem;background:#eef2f5"></div>'
'<p style="max-width:900px;margin-top:.5rem;font-size:.8rem;color:#64748b">'
"Drawn from the stored polygon. A view, not a check: it does not validate the "
"geometry and it does not verify the zone.</p>",
json.dumps(obj.geometry),
settings.LEAFLET_TILE_URL,
browser_tile_url(),
)

View file

@ -34,10 +34,27 @@
var map = L.map(el, { scrollWheelZoom: false });
if (el.dataset.tileUrl) {
L.tileLayer(el.dataset.tileUrl, {
var tiles = L.tileLayer(el.dataset.tileUrl, {
maxZoom: 19,
attribution: OSM_ATTRIBUTION,
}).addTo(map);
// If the tile source cannot be reached, the polygon still draws but the
// background stays blank. Say so, rather than leaving the viewer to wonder
// whether the record itself is broken.
var note = null;
tiles.on("tileerror", function () {
if (note) {
return;
}
note = document.createElement("p");
note.style.cssText =
"max-width:900px;margin-top:.5rem;font-size:.8rem;color:#b45309";
note.textContent =
"Map imagery could not be loaded. The area below is still drawn from the " +
"stored coordinates, so the shape can still be checked.";
el.insertAdjacentElement("afterend", note);
});
}
var layer = L.geoJSON(geometry, {

View file

@ -1,12 +1,23 @@
"""Every portal page must actually render.
"""Cross-cutting checks: every portal page renders, and map tiles are served
from our own origin.
Nothing in the suite was rendering the ops or portal HTML pages, which is how a
view can lose a line — a `template_name`, say — and 500 in front of a user while
68 tests still pass. These sweeps fetch each read-only page with an empty
database, so they cover the empty-state branches as well as the templates.
Neither was covered before. The page sweep exists because a view can lose a
line — a `template_name`, say — and 500 in front of a user while the rest of the
suite stays green; it fetches each read-only page with an empty database, so it
covers the empty-state branches too. The tile tests cover the map's only
external dependency, which used to be fetched by the viewer's browser.
"""
import shutil
import tempfile
import urllib.error
from pathlib import Path
from unittest.mock import MagicMock, patch
from django.conf import settings
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.test import Client, TestCase, override_settings
from .tiles import browser_tile_url, upstream_url
User = get_user_model()
@ -57,3 +68,88 @@ class PortalPagesRenderTests(TestCase):
def test_buyer_pages_render(self):
self.sweep(self.make_user("buyer-sweep@ixg.local", "buyer"), PORTAL_ROUTES, "portal.ixgltd.com")
class TileProxyTests(TestCase):
"""Tiles come from our origin, so the map does not depend on the viewer's
network being able to reach a third-party tile server."""
def setUp(self):
self.cache = Path(tempfile.mkdtemp(prefix="ixg-tile-cache-"))
self.addCleanup(shutil.rmtree, self.cache, True)
override = override_settings(LEAFLET_TILE_CACHE_DIR=str(self.cache))
override.enable()
self.addCleanup(override.disable)
self.user = User.objects.create_user(
email="tile-viewer@ixg.local", password="pass1234", role="admin",
full_name="Tile Viewer", is_staff=True,
)
self.anon = Client(SERVER_NAME="app.ixgltd.com")
self.viewer = Client(SERVER_NAME="app.ixgltd.com")
self.viewer.force_login(self.user)
@staticmethod
def fake_upstream(payload):
"""Stand in for urllib.request.urlopen returning a tile."""
response = MagicMock()
response.read.return_value = payload
context = MagicMock()
context.__enter__.return_value = response
return lambda *a, **k: context
def test_a_viewer_must_be_signed_in(self):
self.assertEqual(self.anon.get("/tiles/13/4085/3448.png").status_code, 403)
def test_tiles_outside_the_zoom_range_are_refused(self):
self.assertEqual(self.viewer.get("/tiles/20/1/1.png").status_code, 404)
# x beyond the span of that zoom level
self.assertEqual(self.viewer.get("/tiles/1/5/1.png").status_code, 404)
def test_a_tile_is_fetched_from_the_upstream_and_served(self):
payload = b"\x89PNG\r\n\x1a\n fake tile"
with patch("apps.core.tiles.urllib.request.urlopen", self.fake_upstream(payload)):
r = self.viewer.get("/tiles/13/4085/3448.png")
self.assertEqual(r.status_code, 200)
self.assertEqual(r["Content-Type"], "image/png")
self.assertEqual(r.content, payload)
self.assertTrue((self.cache / "13" / "4085" / "3448.png").is_file())
def test_a_second_request_is_served_from_the_cache(self):
payload = b"\x89PNG\r\n\x1a\n cached tile"
first = MagicMock(side_effect=self.fake_upstream(payload))
with patch("apps.core.tiles.urllib.request.urlopen", first):
self.viewer.get("/tiles/13/4085/3448.png")
self.assertEqual(first.call_count, 1)
with patch("apps.core.tiles.urllib.request.urlopen") as second:
r = self.viewer.get("/tiles/13/4085/3448.png")
self.assertEqual(second.call_count, 0, "cache should have been used")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.content, payload)
def test_an_unreachable_upstream_answers_an_empty_tile_not_an_error_page(self):
with patch(
"apps.core.tiles.urllib.request.urlopen",
side_effect=urllib.error.HTTPError("u", 403, "Forbidden", None, None),
):
r = self.viewer.get("/tiles/13/4085/3448.png")
self.assertEqual(r.status_code, 204)
self.assertEqual(r.content, b"")
def test_browser_tile_url_is_same_origin_when_the_proxy_is_on(self):
self.assertEqual(browser_tile_url(), "/tiles/{z}/{x}/{y}.png")
def test_browser_tile_url_is_the_upstream_when_the_proxy_is_off(self):
with override_settings(LEAFLET_TILE_PROXY_ENABLED=False):
self.assertEqual(browser_tile_url(), settings.LEAFLET_TILE_URL)
def test_the_upstream_url_gets_the_coordinates_substituted(self):
with override_settings(
LEAFLET_TILE_URL="https://example.invalid/{s}/{z}/{x}/{y}.png"
):
self.assertEqual(
upstream_url(13, 4085, 3448),
"https://example.invalid/a/13/4085/3448.png",
)

112
apps/core/tiles.py Normal file
View file

@ -0,0 +1,112 @@
"""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

View file

@ -143,6 +143,18 @@ LEAFLET_TILE_URL = env(
"LEAFLET_TILE_URL", default="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
)
# Serve tiles from our own origin instead of letting the browser fetch them from
# a third party (apps/core/tiles.py). The map is the only part of the admin page
# that depends on an external domain, and when a viewer's network, proxy or
# browser filter refuses that domain the map silently never draws — the page,
# the Leaflet bundle and the polygon are all same-origin and fine. With this on,
# the browser asks us and we fetch and cache. Set LEAFLET_TILE_PROXY_ENABLED=False
# to go back to direct browser fetches.
LEAFLET_TILE_PROXY_ENABLED = env.bool("LEAFLET_TILE_PROXY_ENABLED", default=True)
LEAFLET_TILE_CACHE_DIR = env(
"LEAFLET_TILE_CACHE_DIR", default=str(MEDIA_ROOT / "tile-cache")
)
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
AUTH_USER_MODEL = "users.User"

View file

@ -5,8 +5,11 @@ from django.conf import settings
from django.conf.urls.static import static
from rest_framework.authtoken import views as drf_auth
from apps.core.tiles import tile_view
urlpatterns = [
path("", lambda r: HttpResponseRedirect("/admin/")),
path("tiles/<int:z>/<int:x>/<int:y>.png", tile_view, name="tile"),
path("grappelli/", include("grappelli.urls")),
path("admin/", admin.site.urls),
path("oidc/", include("mozilla_django_oidc.urls")),