210 lines
6.8 KiB
PHP
210 lines
6.8 KiB
PHP
<?php
|
|
// sat.thedarkelite.com - TLE proxy + cache (fast + stale fallback)
|
|
//
|
|
// CelesTrak provides 3-line sets (name + line1 + line2).[6](https://celestrak.org/norad/documentation/tle-fmt.php)
|
|
// CelesTrak recommends updated access patterns for GP data and supports multiple formats.[2](https://www.celestrak.org/NORAD/documentation/gp-data-formats.php)[1](https://celestrak.org/NORAD/elements/)
|
|
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
|
|
$start = microtime(true);
|
|
$group = isset($_GET['group']) ? strtolower(trim($_GET['group'])) : 'starlink';
|
|
|
|
function gpUrl(string $group, string $format = 'tle'): string {
|
|
// Modern group endpoint (preferred for reliability)
|
|
// See CelesTrak GP data format guidance + Current GP element sets index.[2](https://www.celestrak.org/NORAD/documentation/gp-data-formats.php)[1](https://celestrak.org/NORAD/elements/)
|
|
return 'https://celestrak.org/NORAD/elements/gp.php?GROUP=' . rawurlencode($group) . '&FORMAT=' . rawurlencode($format);
|
|
}
|
|
|
|
// Use gp.php for groups that have shown 404s on static .txt,
|
|
// keep starlink static since it commonly exists and is huge.
|
|
$allowed = [
|
|
'starlink' => ['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');
|
|
}
|