pwaLUPMIS2/public/legend.php
ekke 8c859197ad Legends: clip tall ones and link to the full image; add the coastlines legend
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>
2026-10-21 14:28:12 +02:00

129 lines
4.4 KiB
PHP

<?php
/**
* Same-origin proxy for layer legend images.
*
* WHY THIS EXISTS
* The application is served cross-origin isolated — index.php and the container
* both send `Cross-Origin-Embedder-Policy: require-corp`, which SQLite in a
* worker needs. Under that policy a cross-origin image is blocked unless the
* provider sends `Cross-Origin-Resource-Policy`, and most OGC servers do not.
* A CORS request satisfies COEP on its own, so the client tries the provider
* directly first (see _registerLegend in MapView.js); this proxy is the
* fallback for providers where that still fails. Fetching the image here makes
* it same-origin, which sidesteps the policy entirely.
*
* SECURITY
* This script runs inside the Docker network, where it can reach ApiEngine,
* MainBackend and the database — none of which are reachable from outside. An
* open `?url=` proxy would therefore be a server-side request forgery hole
* straight into them. Hosts are allow-listed, and the resolved address is
* checked against private and reserved ranges as a second line of defence, so
* a DNS entry that later points inward cannot be used either. Redirects are not
* followed, because a 302 to an internal host would defeat both checks.
*
* Legend URLs come from the application's own layer definitions in main.js,
* never from user input, so the allow-list costs nothing today. Adding a host
* is a deliberate decision: it grants that host the right to have its content
* served from our origin.
*/
declare(strict_types=1);
/** Hosts whose legend images may be proxied. */
const ALLOWED_HOSTS = [
'ows.digitalearth.africa', // datacube-ows — sends ACAO but no CORP
'geoserver.digitalearth.africa', // GeoServer — sends neither, so needs this proxy
];
const MAX_BYTES = 2097152; // 2 MB — legends are a few KB; this is a sanity cap
const TIMEOUT_S = 10;
/**
* Refuse without saying why, and without reflecting the requested URL back.
*
* @param int $code
* @return void
*/
function deny($code = 404)
{
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
echo "Legend not available\n";
exit;
}
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
deny(405);
}
$raw = isset($_GET['url']) ? (string) $_GET['url'] : '';
if ($raw === '' || strlen($raw) > 2048) {
deny(400);
}
$parts = parse_url($raw);
if ($parts === false || empty($parts['scheme']) || empty($parts['host'])) {
deny(400);
}
$scheme = strtolower($parts['scheme']);
if ($scheme !== 'http' && $scheme !== 'https') {
deny(400);
}
$host = strtolower($parts['host']);
if (!in_array($host, ALLOWED_HOSTS, true)) {
deny(403);
}
// Second line of defence: even an allow-listed name must not resolve inward.
$ip = gethostbyname($host);
if ($ip === $host) {
deny(502); // name did not resolve
}
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
deny(403); // resolves to a private or reserved address
}
$ch = curl_init($raw);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false, // a redirect could point inward
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => TIMEOUT_S,
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_USERAGENT => 'LUPMIS2-legend-proxy/1.0',
CURLOPT_ACCEPT_ENCODING => '',
// Stop reading once the cap is passed, rather than buffering a huge body.
CURLOPT_BUFFERSIZE => 16384,
CURLOPT_NOPROGRESS => false,
CURLOPT_PROGRESSFUNCTION => function ($res, $dlTotal, $dlNow) {
return ($dlTotal > MAX_BYTES || $dlNow > MAX_BYTES) ? 1 : 0;
},
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$type = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);
if ($body === false || $status !== 200) {
deny(502);
}
if (strlen($body) > MAX_BYTES) {
deny(502);
}
// Only ever hand back an image, whatever the upstream claims to be.
if (stripos($type, 'image/') !== 0) {
deny(502);
}
$clean = explode(';', $type)[0];
header('Content-Type: ' . trim($clean));
header('Content-Length: ' . strlen($body));
header('Cache-Control: public, max-age=86400');
header('X-Content-Type-Options: nosniff');
header('Cross-Origin-Resource-Policy: same-origin');
echo $body;