[ 'label' => 'OpenStreetMap', 'url_pattern' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', 'max_zoom' => 18, ], 'osm-de' => [ 'label' => 'OpenStreetMap Deutsch (FOSSGIS e.V.)', 'url_pattern' => 'https://tile.openstreetmap.de/{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][a-z0-9-]{0,15}$/', $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); }