['name' => 'Starlink', 'url' => 'https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=tle'], 'noaa' => ['name' => 'NOAA', 'url' => 'https://celestrak.org/NORAD/elements/gp.php?GROUP=noaa&FORMAT=tle'], 'weather' => ['name' => 'Weather', 'url' => gpUrl('weather', 'tle')], 'active' => ['name' => 'Active', 'url' => gpUrl('active', 'tle')], 'stations' => ['name' => 'Space Stations', 'url' => gpUrl('stations', 'tle')], ]; if (!isset($allowed[$group])) { http_response_code(400); echo json_encode(['error' => 'Invalid group'], JSON_PRETTY_PRINT); exit; } $cacheDir = __DIR__ . '/cache'; if (!is_dir($cacheDir)) @mkdir($cacheDir, 0775, true); $cacheKey = preg_replace('/[^a-z0-9_\-]/', '_', $group); $cacheFile = $cacheDir . "/tle_{$cacheKey}.json"; // TTL: 2 hours default (CelesTrak updates on a multi-hour cadence; frequent pulls can cause blocks).[7](https://usradioguy.com/wxtoimg-kepler-fix/)[1](https://celestrak.org/NORAD/elements/) $ttlSeconds = 2 * 60 * 60; $now = time(); $emitCache = function(array $cached, bool $isFresh) use ($ttlSeconds, $cacheFile, $now, $start) { $age = is_file($cacheFile) ? ($now - filemtime($cacheFile)) : null; $cached['meta']['cached'] = true; $cached['meta']['cacheAgeSeconds'] = $age; $cached['meta']['cacheTtlSeconds'] = $ttlSeconds; $cached['meta']['servedFrom'] = $isFresh ? 'cache_fresh' : 'cache_stale'; $cached['meta']['serverMs'] = (int)round((microtime(true) - $start) * 1000); header('Cache-Control: public, max-age=60'); echo json_encode($cached, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); exit; }; // Serve fresh cache if (is_file($cacheFile)) { $age = $now - filemtime($cacheFile); if ($age < $ttlSeconds) { $cached = json_decode(@file_get_contents($cacheFile), true); if (is_array($cached)) $emitCache($cached, true); } } // Fetch upstream (fail fast) $url = $allowed[$group]['url']; $ua = 'sat.thedarkelite.com (TLE proxy; contact: admin@thedarkelite.com)'; $text = null; $httpCode = 0; $curlErr = null; $connectTimeout = 4; $totalTimeout = 12; if (function_exists('curl_init')) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_CONNECTTIMEOUT => $connectTimeout, CURLOPT_TIMEOUT => $totalTimeout, CURLOPT_USERAGENT => $ua, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, CURLOPT_ENCODING => '', ]); $text = curl_exec($ch); $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErr = curl_error($ch); curl_close($ch); if ($text === false || $httpCode >= 400) { $text = null; } } if ($text === null) { // Serve stale cache if possible if (is_file($cacheFile)) { $cached = json_decode(@file_get_contents($cacheFile), true); if (is_array($cached)) { $cached['meta']['upstreamError'] = $curlErr ?: "Upstream fetch failed (HTTP $httpCode)"; $cached['meta']['upstream'] = $url; $emitCache($cached, false); } } http_response_code(502); echo json_encode([ 'error' => 'Failed to fetch TLEs from upstream', 'meta' => [ 'group' => $group, 'upstream' => $url, 'httpCode' => $httpCode, 'curlError' => $curlErr, 'serverMs' => (int)round((microtime(true) - $start) * 1000), ] ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); exit; } $parsed = parseTleText($text); $fetchedAtUtc = gmdate('Y-m-d H:i:s'); $out = [ 'meta' => [ 'source' => 'CelesTrak', 'group' => $group, 'groupName' => $allowed[$group]['name'], 'upstream' => $url, 'cached' => false, 'fetchedAtUtc' => $fetchedAtUtc, 'cacheTtlSeconds' => $ttlSeconds, 'count' => count($parsed), 'upstreamHttpCode' => $httpCode, 'serverMs' => (int)round((microtime(true) - $start) * 1000), ], 'satellites' => $parsed ]; @file_put_contents($cacheFile, json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); header('Cache-Control: public, max-age=60'); echo json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); // ---- Helpers ---- function parseTleText(string $text): array { // TLE sets are typically name line + line1 + line2.[6](https://celestrak.org/norad/documentation/tle-fmt.php) $lines = preg_split("/\r\n|\n|\r/", trim($text)); $lines = array_values(array_filter($lines, fn($l) => trim($l) !== '')); $out = []; $i = 0; while ($i < count($lines)) { $name = trim($lines[$i] ?? ''); $l1 = trim($lines[$i+1] ?? ''); $l2 = trim($lines[$i+2] ?? ''); if ($name !== '' && $l1 !== '' && $l2 !== '' && $l1[0] === '1' && $l2[0] === '2') { // NORAD number is columns 03-07 in line 1.[6](https://celestrak.org/norad/documentation/tle-fmt.php) $norad = trim(substr($l1, 2, 5)); if ($norad === '') $norad = '0'; // Epoch year/day are in line1 columns 19-32.[6](https://celestrak.org/norad/documentation/tle-fmt.php) $epochRaw = trim(substr($l1, 18, 14)); $epochStr = epochToIso($epochRaw); $out[] = [ 'name' => $name, 'norad' => $norad, 'line1' => $l1, 'line2' => $l2, 'epoch' => $epochStr ]; $i += 3; continue; } $i += 1; } return $out; } function epochToIso(string $epochRaw): ?string { if ($epochRaw === '' || !preg_match('/^(\d{2})(\d{3}\.\d+)$/', $epochRaw, $m)) return null; $yy = intval($m[1], 10); $day = floatval($m[2]); $year = ($yy >= 57) ? (1900 + $yy) : (2000 + $yy); $dayInt = (int)floor($day); $frac = $day - $dayInt; $dt = new DateTime("$year-01-01 00:00:00", new DateTimeZone("UTC")); $dt->modify('+' . ($dayInt - 1) . ' days'); $seconds = (int)round($frac * 86400); $dt->modify('+' . $seconds . ' seconds'); return $dt->format('Y-m-d H:i:s'); }