eac686ee1a
- Leaflet 1.9.4 (CSS + JS + 5 Marker-Images) lokal unter App/assets/vendor/leaflet/ statt via unpkg.com/Cloudflare - Neuer PHP-Endpunkt App/php/tile-proxy.php mit Disk-Cache, liefert OSM- und CARTO-Kacheln serverseitig aus; Browser kontaktiert keinen Drittanbieter mehr - Whitelist (osm, carto), Zoom 3-18, 30 Tage Browser-Cache, ETag + If-Modified-Since, sauberer User-Agent fuer OSM-Policy - Cache-Pfad: APP_ROOT/data/tiles lokal, via .env TILE_CACHE_DIR auf Produktions-Volume /var/www/html/geograsim/data/tiles - Heli (heli.php + heli-game.php), Logistik (logistik.php), Routes- Dev-Tool und admin-levels.html alle auf Proxy umgestellt - Datenschutzerklaerung: alter Google-AdSense-Passus (falsch, nicht im Einsatz) entfernt, neuer Abschnitt zur serverseitigen Tile-Auslieferung mit Rechtsgrundlage Art. 6 Abs. 1 lit. f DSGVO - .gitignore: App/data/tiles ausgeschlossen - .env.example: TILE_CACHE_DIR + OpenAI/ElevenLabs dokumentiert Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
152 lines
5.4 KiB
PHP
152 lines
5.4 KiB
PHP
<?php
|
|
/**
|
|
* Tile-Proxy mit Disk-Cache — DSGVO-sicherer Ersatz für direkte
|
|
* Browser-Requests an OSM/CARTO-Tile-Server.
|
|
*
|
|
* Flow:
|
|
* Browser → tile-proxy.php (euer Server) → [Cache-Hit? ja: raus]
|
|
* → [Cache-Miss? Upstream holen, cachen, raus]
|
|
*
|
|
* Aufruf: /tile-proxy?z={z}&x={x}&y={y}&p={osm|carto}
|
|
*
|
|
* Konfiguration über .env:
|
|
* TILE_CACHE_DIR=/absoluter/pfad/zum/cache/ (optional;
|
|
* Default: APP_ROOT/data/tiles/)
|
|
*/
|
|
require_once __DIR__ . '/config/app.php';
|
|
|
|
// ============================================================
|
|
// Whitelist — was darf rein, was raus
|
|
// ============================================================
|
|
const PROVIDERS = [
|
|
'osm' => [
|
|
'label' => 'OpenStreetMap',
|
|
'url_pattern' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
|
'max_zoom' => 18,
|
|
],
|
|
'carto' => [
|
|
'label' => 'CARTO Positron',
|
|
'url_pattern' => 'https://cartodb-basemaps-a.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
|
|
'max_zoom' => 18,
|
|
],
|
|
];
|
|
const MIN_ZOOM = 3;
|
|
const MAX_ZOOM = 18;
|
|
const CACHE_TTL_DAYS = 30; // Browser-Cache-Max-Age
|
|
const UPSTREAM_TIMEOUT = 10; // Sekunden
|
|
|
|
// ============================================================
|
|
// Parameter-Validierung
|
|
// ============================================================
|
|
$z = filter_input(INPUT_GET, 'z', FILTER_VALIDATE_INT, ['options' => ['min_range' => MIN_ZOOM, 'max_range' => MAX_ZOOM]]);
|
|
$x = filter_input(INPUT_GET, 'x', FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 1 << MAX_ZOOM]]);
|
|
$y = filter_input(INPUT_GET, 'y', FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 1 << MAX_ZOOM]]);
|
|
$p = filter_input(INPUT_GET, 'p', FILTER_CALLBACK, ['options' => fn($v) => is_string($v) && preg_match('/^[a-z]{1,16}$/', $v) ? $v : false]);
|
|
|
|
if ($z === null || $z === false || $x === false || $y === false || !$p || !isset(PROVIDERS[$p])) {
|
|
http_response_code(400);
|
|
header('Content-Type: text/plain; charset=utf-8');
|
|
exit("Ungültige Parameter. Erwartet: ?z=INT&x=INT&y=INT&p=osm|carto\n");
|
|
}
|
|
|
|
$xMax = (1 << $z) - 1;
|
|
if ($x < 0 || $x > $xMax || $y < 0 || $y > $xMax) {
|
|
http_response_code(400);
|
|
exit("Tile-Koordinaten außerhalb des Zoom-Bereichs.\n");
|
|
}
|
|
|
|
$providerConf = PROVIDERS[$p];
|
|
if ($z > $providerConf['max_zoom']) {
|
|
http_response_code(400);
|
|
exit("Zoom-Level {$z} wird von Provider {$p} nicht unterstützt.\n");
|
|
}
|
|
|
|
// ============================================================
|
|
// Cache-Pfad bestimmen
|
|
// ============================================================
|
|
$cacheDir = defined('TILE_CACHE_DIR')
|
|
? rtrim(TILE_CACHE_DIR, '/\\')
|
|
: rtrim(APP_ROOT, '/\\') . '/data/tiles';
|
|
|
|
$tileDir = $cacheDir . "/$p/$z/$x";
|
|
$tilePath = $tileDir . "/$y.png";
|
|
|
|
// ============================================================
|
|
// Cache-Hit: sofort ausliefern
|
|
// ============================================================
|
|
if (is_file($tilePath) && filesize($tilePath) > 0) {
|
|
_serveTile($tilePath);
|
|
exit;
|
|
}
|
|
|
|
// ============================================================
|
|
// Cache-Miss: upstream holen
|
|
// ============================================================
|
|
$upstreamUrl = strtr($providerConf['url_pattern'], [
|
|
'{z}' => $z, '{x}' => $x, '{y}' => $y,
|
|
]);
|
|
|
|
$ctx = stream_context_create([
|
|
'http' => [
|
|
'method' => 'GET',
|
|
'timeout' => UPSTREAM_TIMEOUT,
|
|
'user_agent' => 'GeoGraSim/1.0 (+https://geograsim.at; educational tile cache)',
|
|
'header' => "Accept: image/png,image/*\r\n",
|
|
],
|
|
'ssl' => [
|
|
'verify_peer' => true,
|
|
'verify_peer_name' => true,
|
|
],
|
|
]);
|
|
|
|
$data = @file_get_contents($upstreamUrl, false, $ctx);
|
|
|
|
if ($data === false || strlen($data) < 100) {
|
|
http_response_code(502);
|
|
header('Content-Type: text/plain; charset=utf-8');
|
|
exit("Upstream nicht erreichbar: {$providerConf['label']}\n");
|
|
}
|
|
|
|
// ============================================================
|
|
// In Cache schreiben (best-effort, Fehler ignorieren)
|
|
// ============================================================
|
|
if (!is_dir($tileDir)) {
|
|
@mkdir($tileDir, 0775, true);
|
|
}
|
|
if (is_dir($tileDir) && is_writable($tileDir)) {
|
|
@file_put_contents($tilePath, $data, LOCK_EX);
|
|
}
|
|
|
|
// ============================================================
|
|
// An Browser ausliefern
|
|
// ============================================================
|
|
header('Content-Type: image/png');
|
|
header('Cache-Control: public, max-age=' . (CACHE_TTL_DAYS * 86400) . ', immutable');
|
|
header('X-Tile-Cache: MISS');
|
|
echo $data;
|
|
|
|
// ============================================================
|
|
// Helper: Tile aus Cache servieren
|
|
// ============================================================
|
|
function _serveTile(string $path): void {
|
|
$etag = '"' . md5_file($path) . '"';
|
|
$mtime = filemtime($path);
|
|
$lastMod = gmdate('D, d M Y H:i:s', $mtime) . ' GMT';
|
|
|
|
header('Content-Type: image/png');
|
|
header('Cache-Control: public, max-age=' . (CACHE_TTL_DAYS * 86400) . ', immutable');
|
|
header('ETag: ' . $etag);
|
|
header('Last-Modified: ' . $lastMod);
|
|
header('X-Tile-Cache: HIT');
|
|
|
|
$ifNoneMatch = $_SERVER['HTTP_IF_NONE_MATCH'] ?? '';
|
|
$ifModSince = $_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? '';
|
|
if ($ifNoneMatch === $etag || ($ifModSince && strtotime($ifModSince) >= $mtime)) {
|
|
http_response_code(304);
|
|
return;
|
|
}
|
|
|
|
header('Content-Length: ' . filesize($path));
|
|
readfile($path);
|
|
}
|