The coastlines layer showed no legend because it was added without a legendUrl,
so _registerLegend was never called. The provider does serve one: GeoServer
renders it on demand through GetLegendGraphic. It is 230 x 2110 px with roughly
105 entries — one shoreline per year plus the rates-of-change classes.
Nothing sensible happens if that is scaled into a 220 px panel. Two columns
halves the label size, four columns without labels is not a legend, and one
column scaled to fit is a 2,000 px scroll. So the image is now shown at natural
size inside a frame capped at 200 px with overflow hidden: the first entries
stay legible at full size, a gradient fades the cut edge, and a caption invites
opening the full legend, which the frame links to in a new tab where neither the
panel geometry nor the cross-origin policy applies. A legend that fits is left
exactly as it was — no fade, no caption. Verified in a browser against both: the
2110 px one clips with fade and caption, the 125 px slope legend does not.
That host sends neither Access-Control-Allow-Origin nor
Cross-Origin-Resource-Policy, so under require-corp both the direct and the CORS
path fail and it has to go through legend.php. It is allow-listed there with a
note on which header each DEAfrica host is missing, since that is what decides
whether the proxy is needed. Verified live: 35,802 bytes of image/png.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The legend image was cancelled by the browser: "violates the resource's
Cross-Origin-Resource-Policy response header". The app is cross-origin isolated
— index.php and the container both send Cross-Origin-Embedder-Policy:
require-corp, which SQLite in a worker needs — and under that policy a
cross-origin image is blocked unless the provider sends
Cross-Origin-Resource-Policy. Digital Earth Africa does not: checked by HEAD and
GET, both origins, with and without a query string, always 200 with
access-control-allow-origin: * and no CORP.
A CORS request satisfies COEP on its own, which is why the base maps have always
worked — every tile source in MapView.js already sets crossOrigin: 'anonymous',
and OpenTopoMap sends only ACAO too. The legend <img> was the one cross-origin
request in the app that did not ask for CORS. It now does, and the element is
built through the DOM rather than innerHTML so the URL is not interpolated into
an attribute.
Because the browser I could test in still refused that particular host at the
network layer — net::ERR_FAILED with no response, which curl never reproduces
from the same machine — this also adds legend.php, a same-origin fallback. The
client tries the provider directly first and falls back once; only if both fail
is the entry hidden.
legend.php is deliberately narrow, because it runs inside the Docker network
where it can reach ApiEngine, MainBackend and the database. Hosts are
allow-listed, the resolved address is rejected if it falls in a private or
reserved range, redirects are not followed, the body is capped at 2 MB and must
be an image, and nothing about the request is reflected back. Verified live: the
allow-listed legend returns a 4,701-byte PNG, while ApiEngine, MainBackend,
169.254.169.254, loopback, an unlisted host and file:// are all refused, as is
any method other than GET. Legend URLs come from the application's own layer
definitions, never from user input, so the allow-list restricts nothing today.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>