Atlas: Tile-Proxy + Leaflet lokal — DSGVO-Sanierung fuer Kartendienste

- 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>
This commit is contained in:
2026-04-20 00:53:43 +02:00
parent 3885c83294
commit eac686ee1a
15 changed files with 1380 additions and 8 deletions
+151
View File
@@ -0,0 +1,151 @@
<?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);
}