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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user